---
title: Build Agents on Cloudflare
description: Create stateful AI agents with persistent memory, real-time WebSocket connections, and scheduled tasks using the Cloudflare Agents SDK.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Build Agents on Cloudflare

Build and host Agents on Cloudflare, connect chat, voice, email, Slack, and webhooks to a durable agent runtime with Browser, Sandbox, AI Search, MCP, Payments, and other MCP tools.

When you host agents on Cloudflare, each agent session has a durable identity, local SQL storage, real-time connections, scheduled work, and recoverable execution.

Deploy once and Cloudflare runs your agents across its global network, scaling to tens of millions of instances. No infrastructure to manage, no sessions to reconstruct, no state to externalize.

[ Chat ](https://developers.cloudflare.com/agents/communication-channels/chat/)[ Email ](https://developers.cloudflare.com/agents/communication-channels/email/)[ Voice ](https://developers.cloudflare.com/agents/communication-channels/voice/)[ Slack ](https://developers.cloudflare.com/agents/communication-channels/slack/)[ Webhook ](https://developers.cloudflare.com/agents/communication-channels/webhooks/) 

Agent harness

Controls planning, tool use, and response flow.

[Project Think](https://developers.cloudflare.com/agents/harnesses/think/) [Build-your-own agent](https://developers.cloudflare.com/agents/runtime/agents-api/) 

Agents SDK runtime

Durable identity, state, connections, scheduling, and recovery. 

[Agent class](https://developers.cloudflare.com/agents/runtime/agents-api/) 

[State](https://developers.cloudflare.com/agents/runtime/lifecycle/state/)[Sessions](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/)[Routing](https://developers.cloudflare.com/agents/runtime/communication/routing/)[WebSockets](https://developers.cloudflare.com/agents/runtime/communication/websockets/)[Scheduling](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/)[Fibers](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/) 

[ Sandbox ](https://developers.cloudflare.com/agents/tools/sandbox/)[ MCP ](https://developers.cloudflare.com/agents/tools/mcp/)[ Browser ](https://developers.cloudflare.com/agents/tools/browser/)[ AI Search ](https://developers.cloudflare.com/agents/tools/ai-search/)[ Payments ](https://developers.cloudflare.com/agents/tools/payments/) 

[ Observability Logs · metrics · traces ](https://developers.cloudflare.com/agents/runtime/operations/observability/) 

Agents on Cloudflare are composed from four parts:

* **Communication channels** define how users and systems reach your agent, such as [chat](https://developers.cloudflare.com/agents/communication-channels/chat/), [voice](https://developers.cloudflare.com/agents/communication-channels/voice/), [email](https://developers.cloudflare.com/agents/communication-channels/email/), [Slack](https://developers.cloudflare.com/agents/communication-channels/slack/), [webhooks](https://developers.cloudflare.com/agents/communication-channels/webhooks/), and other event sources.
* **The agent harness** defines the loop: how the agent calls models, selects tools, handles tool results, streams responses, and decides whether to continue. Use [Project Think](https://developers.cloudflare.com/agents/harnesses/think/) for an opinionated harness, or build your own loop directly on the [Agents SDK runtime](https://developers.cloudflare.com/agents/runtime/agents-api/).
* **The Agents SDK runtime** provides durable infrastructure: the [Agent class](https://developers.cloudflare.com/agents/runtime/lifecycle/agent-class/), [state](https://developers.cloudflare.com/agents/runtime/lifecycle/state/), [sessions](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/), [routing](https://developers.cloudflare.com/agents/runtime/communication/routing/), [WebSockets](https://developers.cloudflare.com/agents/runtime/communication/websockets/), [scheduling](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/), [fibers](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/), and [observability](https://developers.cloudflare.com/agents/runtime/operations/observability/).
* **Tools** give the agent capabilities: [browser automation](https://developers.cloudflare.com/agents/tools/browser/), [sandboxed code execution](https://developers.cloudflare.com/agents/tools/sandbox/), [AI Search](https://developers.cloudflare.com/agents/tools/ai-search/), [MCP tools](https://developers.cloudflare.com/agents/tools/mcp/), and [payments](https://developers.cloudflare.com/agents/tools/payments/). [Code Mode](https://developers.cloudflare.com/agents/tools/codemode/) lets models discover and orchestrate multiple tools by writing code.

### Get started

Three commands to a running agent. No API keys required — the starter uses [Workers AI](https://developers.cloudflare.com/workers-ai/) by default.

Terminal window

```
npx create-cloudflare@latest --template cloudflare/agents-startercd agents-starter && npm installnpm run dev
```

The starter includes streaming AI chat, server-side and client-side tools, human-in-the-loop approval, and task scheduling — a foundation you can build on or tear apart. You can also swap in [OpenAI, Anthropic, Google Gemini, or any other provider](https://developers.cloudflare.com/agents/runtime/operations/using-ai-models/).

### Example agents

**[Chat agent](https://developers.cloudflare.com/agents/examples/chat-agent/)** 

Build a streaming AI chat agent with tools and human-in-the-loop approvals.

**[Slack agent](https://developers.cloudflare.com/agents/examples/slack-agent/)** 

Build an agent that responds to Slack messages, mentions, and commands.

**[Voice agent](https://developers.cloudflare.com/agents/examples/voice-agent/)** 

Build a real-time voice agent with speech-to-text and text-to-speech.

**[Browser agent](https://developers.cloudflare.com/agents/examples/browser-agent/)** 

Build an agent that can inspect pages, capture screenshots, and use browser tools.

**[Email agent](https://developers.cloudflare.com/agents/examples/email-agent/)** 

Build an agent that sends, receives, routes, and replies to email.

```json
{"@context":"https://schema.org","@type":"WebPage","@id":"https://developers.cloudflare.com/agents/#page","headline":"Agents · Cloudflare Agents docs","description":"Create stateful AI agents with persistent memory, real-time WebSocket connections, and scheduled tasks using the Cloudflare Agents SDK.","url":"https://developers.cloudflare.com/agents/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}}]}
```

---

---
title: Tools
description: Tools give agents capabilities such as browsing the web, processing payments, and running code. Agents can call tools in different ways. Use Code Mode to let models discover and orchestrate multiple tools by writing code, or use direct tool calls for simple actions.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Tools

Tools give agents capabilities such as browsing the web, processing payments, and running code. Agents can call tools in different ways. Use [Code Mode](https://developers.cloudflare.com/agents/tools/codemode/) to let models discover and orchestrate multiple tools by writing code, or use direct tool calls for simple actions.

* [ Sandbox ](https://developers.cloudflare.com/agents/tools/sandbox/)
* [ MCP ](https://developers.cloudflare.com/agents/tools/mcp/)
* [ Browser ](https://developers.cloudflare.com/agents/tools/browser/)
* [ AI Search ](https://developers.cloudflare.com/agents/tools/ai-search/)
* [ Agentic Payments ](https://developers.cloudflare.com/agents/tools/payments/)
* [ Code Mode ](https://developers.cloudflare.com/agents/tools/codemode/)

```json
{"@context":"https://schema.org","@type":"WebPage","@id":"https://developers.cloudflare.com/agents/tools/#page","headline":"Tools · Cloudflare Agents docs","description":"Tools give agents capabilities such as browsing the web, processing payments, and running code. Agents can call tools in different ways. Use Code Mode to let models discover and orchestrate multiple tools by writing code, or use direct tool calls for simple actions.","url":"https://developers.cloudflare.com/agents/tools/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}}]}
```

---

---
title: AI Search
description: Give agents retrieval capabilities with Cloudflare AI Search.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# AI Search

Agents can use [AI Search](https://developers.cloudflare.com/ai-search/) to retrieve relevant information from indexed content and use it to augment [calls to AI models](https://developers.cloudflare.com/agents/runtime/operations/using-ai-models/). AI Search manages the retrieval pipeline for you, including indexing, search, and optional chat completions over your content.

Use AI Search when you want an agent to:

* Search product docs, support content, user files, or internal knowledge bases.
* Retrieve relevant chunks before calling a model.
* Use managed indexing instead of building retrieval infrastructure yourself.
* Query content from an R2 bucket, website, or uploaded files.

## Basic pattern

Bind AI Search to your Worker, then query an instance from an agent method.

* [  JavaScript ](#tab-panel-6731)
* [  TypeScript ](#tab-panel-6732)

JavaScript

```
import { Agent, callable } from "agents";
export class SearchAgent extends Agent {  @callable()  async searchKnowledge(query) {    const instance = this.env.AI_SEARCH.get("my-instance");
    const results = await instance.search({      messages: [{ role: "user", content: query }],    });
    return results;  }}
```

TypeScript

```
import { Agent, callable } from "agents";
type Env = {  AI_SEARCH: AiSearchNamespace;};
export class SearchAgent extends Agent<Env> {  @callable()  async searchKnowledge(query: string) {    const instance = this.env.AI_SEARCH.get("my-instance");
    const results = await instance.search({      messages: [{ role: "user", content: query }],    });
    return results;  }}
```

For answer generation, use `chatCompletions()` to retrieve relevant content and generate a response in one call.

* [  JavaScript ](#tab-panel-6729)
* [  TypeScript ](#tab-panel-6730)

JavaScript

```
const instance = this.env.AI_SEARCH.get("my-instance");
const response = await instance.chatCompletions({  messages: [{ role: "user", content: "How do I deploy an Agent?" }],  model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast",  ai_search_options: {    retrieval: {      max_num_results: 5,    },  },});
```

TypeScript

```
const instance = this.env.AI_SEARCH.get("my-instance");
const response = await instance.chatCompletions({  messages: [{ role: "user", content: "How do I deploy an Agent?" }],  model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast",  ai_search_options: {    retrieval: {      max_num_results: 5,    },  },});
```

## Configuration

Use an `ai_search_namespaces` binding when the agent needs to access AI Search instances by name.

* [  wrangler.jsonc ](#tab-panel-6727)
* [  wrangler.toml ](#tab-panel-6728)

JSONC

```
{  "ai_search_namespaces": [    {      "binding": "AI_SEARCH",      "namespace": "default",      "remote": true    }  ]}
```

TOML

```
[[ai_search_namespaces]]binding = "AI_SEARCH"namespace = "default"remote = true
```

Use `remote: true` to query deployed AI Search instances during local development with `wrangler dev`.

## Related resources

[ AI Search ](https://developers.cloudflare.com/ai-search/) Create managed retrieval pipelines over websites, R2 buckets, and uploaded files. 

[ Workers binding ](https://developers.cloudflare.com/ai-search/api/search/workers-binding/) Query AI Search directly from Workers code. 

[ Create an AI Search instance ](https://developers.cloudflare.com/ai-search/get-started/) Create your first AI Search instance and run your first query.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/ai-search/#page","headline":"AI Search · Cloudflare Agents docs","description":"Give agents retrieval capabilities with Cloudflare AI Search.","url":"https://developers.cloudflare.com/agents/tools/ai-search/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/ai-search/","name":"AI Search"}}]}
```

---

---
title: Browser
description: Give Agents full Chrome DevTools Protocol access to inspect pages, scrape data, and capture screenshots with Browser Run.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Browser

Agents can use [Browser Run](https://developers.cloudflare.com/browser-run/) to inspect and interact with web pages through the [Chrome DevTools Protocol (CDP)](https://developers.cloudflare.com/browser-run/cdp/). Beta Browser tools are useful when an agent needs to understand rendered pages, capture screenshots, debug frontend behavior, or extract information that is only available after JavaScript runs.

Instead of a fixed set of browser actions (click, screenshot, navigate), the model writes code that runs CDP commands against a live browser session through the `cdp` connector — accessing all domains, commands, events, and types in the protocol. Executions use the [durable Code Mode runtime](https://developers.cloudflare.com/agents/tools/codemode/how-it-works/), so a run can pause for approval and resume with its browser session intact.

Use browser tools when you want an agent to:

* Open and inspect live web pages.
* Capture screenshots or page state.
* Scrape rendered content that is not present in static HTML.
* Debug frontend issues using CDP commands.
* Combine page inspection with other tools, such as RAG or Sandbox.

## How it works

Browser Run provides isolated browser sessions that agents can control with CDP. The agent can navigate pages, evaluate JavaScript, read DOM state, capture screenshots, and inspect network or console output.

Because browser sessions run outside the Worker isolate, use them for work that needs a real browser environment rather than lightweight HTTP fetches.

## Basic pattern

Create browser tools with the Browser Run and Worker Loader bindings, then pass those tools to your model call.

* [  JavaScript ](#tab-panel-6747)
* [  TypeScript ](#tab-panel-6748)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { createBrowserTools } from "agents/browser/ai";import { streamText, convertToModelMessages, stepCountIs } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class BrowserAgent extends AIChatAgent {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });    const browserTools = createBrowserTools({      ctx: this.ctx,      browser: this.env.BROWSER,      loader: this.env.LOADER,    });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      system: "You can inspect web pages with browser tools.",      messages: await convertToModelMessages(this.messages),      tools: browserTools,      stopWhen: stepCountIs(10),    });
    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { createBrowserTools } from "agents/browser/ai";import { streamText, convertToModelMessages, stepCountIs } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class BrowserAgent extends AIChatAgent<Env> {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });    const browserTools = createBrowserTools({      ctx: this.ctx,      browser: this.env.BROWSER,      loader: this.env.LOADER,    });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      system: "You can inspect web pages with browser tools.",      messages: await convertToModelMessages(this.messages),      tools: browserTools,      stopWhen: stepCountIs(10),    });
    return result.toUIMessageStreamResponse();  }}
```

Browser tools must be created from inside a Durable Object (such as an Agent) — the durable runtime facet and the session store live on its `ctx`. The helper exposes one durable CDP tool plus stateless Quick Action tools when a `browser` binding is present:

| Tool              | Description                                                                                                   |
| ----------------- | ------------------------------------------------------------------------------------------------------------- |
| browser\_execute  | Run sandboxed code against a live browser over CDP — screenshots, DOM reads, JavaScript evaluation, and more. |
| browser\_markdown | Read a page or raw HTML as Markdown.                                                                          |
| browser\_extract  | Extract structured data from a page with AI.                                                                  |
| browser\_links    | List links on a page.                                                                                         |
| browser\_scrape   | Scrape specific elements by CSS selector.                                                                     |

To discover protocol surface, the model calls `cdp.spec()` (the live, normalized CDP protocol description) or the runtime's built-in [codemode.search() and codemode.describe()](https://developers.cloudflare.com/agents/tools/codemode/api-reference/#sandbox-codemode-api).

## Configuration

Add the Browser Run and Worker Loader bindings to `wrangler.jsonc`.

* [  wrangler.jsonc ](#tab-panel-6733)
* [  wrangler.toml ](#tab-panel-6734)

JSONC

```
{  "compatibility_flags": ["nodejs_compat"],  "browser": {    "binding": "BROWSER"  },  "worker_loaders": [    {      "binding": "LOADER"    }  ]}
```

TOML

```
compatibility_flags = [ "nodejs_compat" ]
[browser]binding = "BROWSER"
[[worker_loaders]]binding = "LOADER"
```

The durable runtime behind the tool lives in a Durable Object facet, so your Worker entry must export it (the `@cloudflare/codemode/vite` plugin does this automatically):

* [  JavaScript ](#tab-panel-6735)
* [  TypeScript ](#tab-panel-6736)

JavaScript

```
export { CodemodeRuntime } from "agents/browser";
```

TypeScript

```
export { CodemodeRuntime } from "agents/browser";
```

`agents/browser` re-exports the Code Mode runtime for browser tool setups. Code Mode-specific examples can also import `CodemodeRuntime` from `@cloudflare/codemode`.

## Session lifecycle

By default each execution gets a fresh browser session, torn down when the run ends (`one-shot`). Pass a `session` option for two more modes:

* [  JavaScript ](#tab-panel-6737)
* [  TypeScript ](#tab-panel-6738)

JavaScript

```
createBrowserTools({  ctx: this.ctx,  browser: this.env.BROWSER,  loader: this.env.LOADER,  session: { mode: "dynamic" }, // or { mode: "reuse", key: "main" }});
```

TypeScript

```
createBrowserTools({  ctx: this.ctx,  browser: this.env.BROWSER,  loader: this.env.LOADER,  session: { mode: "dynamic" }, // or { mode: "reuse", key: "main" }});
```

* **`one-shot`** (default) — fresh session per execution; deterministic cleanup when the execution reaches a terminal status.
* **`reuse`** — a named shared session that persists across executions until explicitly closed or swept.
* **`dynamic`** — starts one-shot; the model can promote the session with `cdp.startSession()` (for example, after logging in to a page) so later executions continue in the same browser.

In `reuse` and `dynamic` modes the sandbox additionally gets `cdp.startSession()`, `cdp.sessionInfo()`, `cdp.closeSession()`, and `cdp.resetSession()`.

Sessions are tracked durably in the Durable Object's storage, so they survive hibernation and approval pauses — a run that pauses for human approval resumes with its browser session, tabs, and cookies intact. If Browser Run expires the session while a pause waits, the resume surfaces a clear error and the model starts over.

For host-side wiring (session inspection, cleanup, reclaiming stale pauses), use `createBrowserRuntime`, which returns `{ runtime, connector, tools }`. Call `connector.sweep()` from a scheduled task to reclaim expired or stale sessions, and `runtime.expirePaused()` to reject stale never-approved pauses.

## Quick actions

Use `browser_execute` for interactive, multi-step automation. For one-shot browsing tasks, use [Browser Run Quick Actions](https://developers.cloudflare.com/browser-run/quick-actions/). Quick Actions need only the `browser` binding, so they do not need a Worker Loader or sandbox.

* [  JavaScript ](#tab-panel-6739)
* [  TypeScript ](#tab-panel-6740)

JavaScript

```
import { createQuickActionTools } from "agents/browser/ai";
const tools = createQuickActionTools({ browser: this.env.BROWSER });// browser_markdown, browser_extract, browser_links, browser_scrape
```

TypeScript

```
import { createQuickActionTools } from "agents/browser/ai";
const tools = createQuickActionTools({ browser: this.env.BROWSER });// browser_markdown, browser_extract, browser_links, browser_scrape
```

By default, `createBrowserTools` and `createBrowserRuntime` include Quick Action tools whenever a `browser` binding is present. Pass `quickActions: false` to keep only `browser_execute`, or pass `quickActions: { actions, maxChars, options }` to configure the stateless tools.

* [  JavaScript ](#tab-panel-6741)
* [  TypeScript ](#tab-panel-6742)

JavaScript

```
createBrowserTools({  browser: this.env.BROWSER,  loader: this.env.LOADER,  quickActions: { maxChars: 20_000 },});
```

TypeScript

```
createBrowserTools({  browser: this.env.BROWSER,  loader: this.env.LOADER,  quickActions: { maxChars: 20_000 },});
```

Every Quick Action result is bounded to `maxChars` to protect the model context window while preserving the result shape. Host-supplied request options, such as `cookies`, `authenticate`, `gotoOptions`, and `viewport`, are passed once through `options` and are not exposed to the model.

Quick Actions require a Worker `compatibility_date` of `2026-03-24` or later and `remote: true` on the browser binding for local `wrangler dev`.

## Live View and human-in-the-loop

[Live View](https://developers.cloudflare.com/browser-run/features/live-view/) lets a human watch or control a running browser session in real time. Use it for human-in-the-loop steps such as login, MFA, CAPTCHA, or sensitive input.

Because the Code Mode runtime can pause a run with the browser session intact, a handoff follows this pattern:

1. The model calls `cdp.getLiveViewUrl()` to get a link to the current tab.
2. The agent surfaces the link to the user.
3. The model makes an approval-gated call, so the run pauses durably.
4. After approval, the run resumes against the same session.

* [  JavaScript ](#tab-panel-6743)
* [  TypeScript ](#tab-panel-6744)

JavaScript

```
async () => {  const { targetId } = await cdp.send({    method: "Target.createTarget",    params: { url: "https://example.com/login" },  });
  const { url } = await cdp.getLiveViewUrl({ targetId, mode: "tab" });  return { needsHumanLogin: url };};
```

TypeScript

```
async () => {  const { targetId } = await cdp.send({    method: "Target.createTarget",    params: { url: "https://example.com/login" },  });
  const { url } = await cdp.getLiveViewUrl({ targetId, mode: "tab" });  return { needsHumanLogin: url };};
```

Pass `mode: "tab"` for an interactive page view, or `mode: "devtools"` for the full DevTools inspector. The URL is valid for about five minutes. Call `cdp.getLiveViewUrl()` again to create a fresh URL.

From the host side, `connector.liveView()` returns Live View URLs for the shared session's tabs. Each tab includes its current `pageUrl`, so an agent UI can label tabs and skip blank or internal pages.

## Session recording

[Session recording](https://developers.cloudflare.com/browser-run/features/session-recording/) captures a Browser Run session as structured rrweb events. Use recordings to audit or debug what an autonomous browser run did after the session closes.

Opt in per session with `recording: true`:

* [  JavaScript ](#tab-panel-6745)
* [  TypeScript ](#tab-panel-6746)

JavaScript

```
import { createBrowserRuntime } from "agents/browser/ai";
const { connector } = createBrowserRuntime({  ctx: this.ctx,  browser: this.env.BROWSER,  loader: this.env.LOADER,  session: { mode: "reuse", key: "main", recording: true },});
```

TypeScript

```
import { createBrowserRuntime } from "agents/browser/ai";
const { connector } = createBrowserRuntime({  ctx: this.ctx,  browser: this.env.BROWSER,  loader: this.env.LOADER,  session: { mode: "reuse", key: "main", recording: true },});
```

A recording is finalized after the session closes. Capture the session ID while the session is alive, then fetch the recording from the Browser Rendering REST API:

* [  JavaScript ](#tab-panel-6749)
* [  TypeScript ](#tab-panel-6750)

JavaScript

```
import { getBrowserRecording } from "agents/browser";
const { sessionId } = (await connector.sessionInfo()) ?? {};if (!sessionId) {  throw new Error("No active browser session");}
const recording = await getBrowserRecording({  accountId: this.env.CF_ACCOUNT_ID,  apiToken: this.env.CF_API_TOKEN,  sessionId,});
```

TypeScript

```
import { getBrowserRecording } from "agents/browser";
const { sessionId } = (await connector.sessionInfo()) ?? {};if (!sessionId) {  throw new Error("No active browser session");}
const recording = await getBrowserRecording({  accountId: this.env.CF_ACCOUNT_ID,  apiToken: this.env.CF_API_TOKEN,  sessionId,});
```

Recordings are retained for 30 days and capped at two hours per session. Be deliberate with recording on shared `reuse` and `dynamic` sessions because the recording spans the full session lifetime.

## CDP connector API

Inside `browser_execute`, the `cdp` namespace provides the following methods. All methods take a single object argument:

| Method                                                | Description                                                                     |
| ----------------------------------------------------- | ------------------------------------------------------------------------------- |
| cdp.send({ method, params?, sessionId?, timeoutMs? }) | Send a CDP command and wait for the response.                                   |
| cdp.attachToTarget({ targetId, timeoutMs? })          | Attach to a target; returns { sessionId } for page-scoped send calls.           |
| cdp.spec()                                            | The searchable, normalized CDP protocol spec.                                   |
| cdp.getDebugLog({ limit? })                           | Recent CDP traffic (sends, receives, warnings) for this execution's connection. |
| cdp.clearDebugLog()                                   | Clear the debug log buffer.                                                     |
| cdp.getLiveViewUrl({ targetId?, mode? })              | Create a Live View URL for a tab.                                               |
| cdp.startSession() _(reuse/dynamic)_                  | Promote or ensure the shared session; returns its info.                         |
| cdp.sessionInfo() _(reuse/dynamic)_                   | Shared session info, or null.                                                   |
| cdp.closeSession() _(reuse/dynamic)_                  | Close the shared session.                                                       |
| cdp.resetSession() _(reuse/dynamic)_                  | Close and replace the shared session.                                           |

Every `cdp.*` call is recorded in the runtime's durable log. If a run pauses (for approval) or the sandbox aborts, resuming replays the log and continues — so connector calls must be sequential and deterministic. Model code must not `Promise.all` CDP calls (the tool instructions enforce this), and the returned `sessionId` is a stable session handle that stays valid across pause/resume reconnects.

Note

Using `@cloudflare/think`? The unified execute tool (`createExecuteTool(this)`) already includes `cdp.*` alongside `state.*` and `tools.*` when `env.BROWSER` is bound. Refer to [Think tools](https://developers.cloudflare.com/agents/harnesses/think/tools/).

## Build a browser agent

For a complete walkthrough, including Browser Run setup, tool definitions, and screenshot capture, use the browser agent example.

[ Browser agent ](https://developers.cloudflare.com/agents/examples/browser-agent/) Build an agent that can browse the web, inspect pages, capture screenshots, and debug frontend issues. 

## Related resources

[ Browser Run ](https://developers.cloudflare.com/browser-run/) Run browser automation on Cloudflare. 

[ Chrome DevTools Protocol ](https://developers.cloudflare.com/browser-run/cdp/) Use CDP commands, events, and types with Browser Run.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/browser/#page","headline":"Browser · Cloudflare Agents docs","description":"Give Agents full Chrome DevTools Protocol access to inspect pages, scrape data, and capture screenshots with Browser Run.","url":"https://developers.cloudflare.com/agents/tools/browser/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/browser/","name":"Browser"}}]}
```

---

---
title: Code Mode
description: Use Code Mode to let models discover and compose tools by writing code as a compact, executable plan.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Code Mode

Code Mode is a tool-use pattern where a model writes code instead of requesting each operation separately. The model receives one code-execution tool. Its code becomes a compact plan that calls tools, processes results, and returns the information needed for a response.

Code Mode exposes configured tools as typed methods. Models use those methods to express multi-step work with familiar programming constructs. Depending on the integration, tool definitions can be provided up front or discovered when needed.

Note

Code Mode is experimental and may introduce breaking changes. Use caution in production.

## Code as a plan

With direct tool use, the model selects one tool, receives its result, and then decides what to do next. Each operation can require another round trip through the model.

Code Mode moves that intermediate logic into executable code. A single plan can:

* Compose several dependent tool calls.
* Loop over collections of results.
* Filter and transform returned data.
* Branch based on earlier results.
* Shape the final returned value.

This approach keeps control flow and data handling together. It is useful when the model must coordinate several operations before producing an answer.

## Progressive tool discovery

Large tool catalogs can consume significant model context if every definition is loaded up front. Connector-based Code Mode runtimes support progressive discovery through `codemode.search()` and `codemode.describe()`.

Search finds relevant connectors, methods, and saved snippets. Describe returns detailed type information for a selected target. These results enter the running code, so the model can pull the definitions it needs instead of receiving the entire catalog with every request.

## Choose between Code Mode and direct tool calls

With direct tool calls, each result returns to the model before it chooses the next operation. Intermediate data consumes context, even when the model only needs a small part of it for the final answer.

Code Mode keeps that work inside one sandbox execution. Generated code can pass results between tools, filter intermediate data, and return only the final value the model needs. This makes multi-tool tasks more efficient and easier to generalize or repeat.

Use direct tool calls for simple tasks with a small, fixed tool set. Use Code Mode when a task needs composition, dependent calls, progressive discovery, reusable logic, or control flow:

| Pattern           | Best suited for                                                                                                  |
| ----------------- | ---------------------------------------------------------------------------------------------------------------- |
| Direct tool calls | Simple tasks using a small, known tool set                                                                       |
| Code Mode         | Composed or dependent calls, large tool catalogs, loops, branching, filtering, result shaping, or reusable logic |

## Choose an integration

Code Mode provides surfaces for agent runtimes, AI frameworks, browsers, and Model Context Protocol (MCP) systems.

[ Durable runtime ](https://developers.cloudflare.com/agents/tools/codemode/durable-runtime/) Create a runtime with connectors, approvals, replay, and snippets. 

[ AI SDK ](https://developers.cloudflare.com/agents/tools/codemode/ai-sdk/) Combine AI SDK tools into one Code Mode tool. 

[ TanStack AI ](https://developers.cloudflare.com/agents/tools/codemode/tanstack-ai/) Use Code Mode with TanStack AI tools and chat applications. 

[ Browser ](https://developers.cloudflare.com/agents/tools/codemode/browser/) Run generated code against browser-owned tools in an isolated iframe. 

[ Model Context Protocol ](https://developers.cloudflare.com/agents/tools/codemode/mcp/) Expose tools from an Agents SDK MCP connection inside Code Mode. 

[ OpenAPI ](https://developers.cloudflare.com/agents/tools/codemode/openapi/) Derive typed connector methods from an OpenAPI service. 

## Understand the pattern

[ How Code Mode works ](https://developers.cloudflare.com/agents/tools/codemode/how-it-works/) Understand how a generated plan discovers, calls, and combines tools. 

[ API reference ](https://developers.cloudflare.com/agents/tools/codemode/api-reference/) Look up Code Mode package exports, types, and options.

```json
{"@context":"https://schema.org","@type":"WebPage","@id":"https://developers.cloudflare.com/agents/tools/codemode/#page","headline":"Code Mode · Cloudflare Agents docs","description":"Use Code Mode to let models discover and compose tools by writing code as a compact, executable plan.","url":"https://developers.cloudflare.com/agents/tools/codemode/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/codemode/","name":"Code Mode"}}]}
```

---

---
title: AI SDK integration
description: Use AI SDK tools with Code Mode through createCodeTool(), namespaced providers, or ToolSetConnector for durable execution.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# AI SDK integration

The `@cloudflare/codemode/ai` entry point converts AI SDK tools into one Code Mode tool. The model writes JavaScript that calls your tools, and an executor runs that code in an isolated sandbox.

Choose between two integration patterns:

| Pattern                                | Use case                                                    | Approval behavior                              |
| -------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------- |
| createCodeTool()                       | Simple, stateless execution with one or more tool providers | Excludes tools that use needsApproval          |
| ToolSetConnector or toolSetConnector() | Durable execution through a Code Mode runtime               | Maps needsApproval to durable runtime approval |

## Create a stateless Code Mode tool

`createCodeTool()` accepts an AI SDK `ToolSet` or an array of tool providers. It also requires an executor. It returns a standard AI SDK tool for use with `streamText()` or `generateText()`.

1. Install Code Mode, the AI SDK, and Zod:  
 npm  yarn  pnpm  bun  
```  
npm i @cloudflare/codemode agents ai zod  
```  
```  
yarn add @cloudflare/codemode agents ai zod  
```  
```  
pnpm add @cloudflare/codemode agents ai zod  
```  
```  
bun add @cloudflare/codemode agents ai zod  
```
2. Add a Worker Loader binding for `DynamicWorkerExecutor`:

  * [  wrangler.jsonc ](#tab-panel-6751)
  * [  wrangler.toml ](#tab-panel-6752)  
JSONC  
```  
{  "$schema": "./node_modules/wrangler/config-schema.json",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": [    "nodejs_compat"  ],  "worker_loaders": [    {      "binding": "LOADER"    }  ]}  
```  
TOML  
```  
# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = ["nodejs_compat"]  
[[worker_loaders]]binding = "LOADER"  
```
3. Define executable AI SDK tools. Code Mode uses their schemas to generate types and validate arguments before calling `execute`.

  * [  JavaScript ](#tab-panel-6755)
  * [  TypeScript ](#tab-panel-6756)  
src/tools.js  
```  
import { tool } from "ai";import { z } from "zod";  
export const weatherTools = {  getWeather: tool({    description: "Get the weather for a city",    inputSchema: z.object({      city: z.string().describe("City name"),    }),    outputSchema: z.object({      city: z.string(),      conditions: z.string(),    }),    execute: async ({ city }) => ({      city,      conditions: "sunny",    }),  }),};  
```  
src/tools.ts  
```  
import { tool } from "ai";import { z } from "zod";  
export const weatherTools = {  getWeather: tool({    description: "Get the weather for a city",    inputSchema: z.object({      city: z.string().describe("City name")    }),    outputSchema: z.object({      city: z.string(),      conditions: z.string()    }),    execute: async ({ city }) => ({      city,      conditions: "sunny"    })  })};  
```  
Each sandbox-callable tool needs an `execute` function. Client-side or provider-executed tools cannot run through this server-side executor.
4. Create the Code Mode tool and pass it to an AI SDK model call:

  * [  JavaScript ](#tab-panel-6759)
  * [  TypeScript ](#tab-panel-6760)  
src/index.js  
```  
import { DynamicWorkerExecutor } from "@cloudflare/codemode";import { createCodeTool } from "@cloudflare/codemode/ai";import { generateText, stepCountIs } from "ai";import { model } from "./model";import { weatherTools } from "./tools";  
export default {  async fetch(request, env) {    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });    const codemode = createCodeTool({ tools: weatherTools, executor });  
    const response = await generateText({      model,      prompt: await request.text(),      tools: { codemode },      stopWhen: stepCountIs(5),    });  
    return new Response(response.text);  },};  
```  
src/index.ts  
```  
import { DynamicWorkerExecutor } from "@cloudflare/codemode";import { createCodeTool } from "@cloudflare/codemode/ai";import { generateText, stepCountIs } from "ai";import { model } from "./model";import { weatherTools } from "./tools";  
export default {   async fetch(request, env): Promise<Response> {     const executor = new DynamicWorkerExecutor({ loader: env.LOADER });     const codemode = createCodeTool({ tools: weatherTools, executor });  
     const response = await generateText({       model,       prompt: await request.text(),       tools: { codemode },       stopWhen: stepCountIs(5),     });  
     return new Response(response.text);   },} satisfies ExportedHandler<Env>;  
```

The example uses `generateText()` for a completed response. You can pass the same `codemode` tool to `streamText()` for streaming. The generated tool description includes TypeScript definitions for `getWeather`. The model still writes JavaScript, such as:

JavaScript

```
async () => {  const weather = await codemode.getWeather({ city: "Lisbon" });  return weather.conditions;};
```

The default namespace is `codemode`. `createCodeTool()` also accepts a custom `description`. Include `{{types}}` in that description where Code Mode should insert the generated definitions.

## Organize tools with providers

A tool provider groups tools under one sandbox namespace. Pass the tool set directly when every tool belongs under `codemode.*`.

Use `aiTools()` when combining AI SDK tools with providers from other packages. The following optional workspace example also requires `@cloudflare/shell`:

 npm  yarn  pnpm  bun 

```
npm i @cloudflare/shell
```

```
yarn add @cloudflare/shell
```

```
pnpm add @cloudflare/shell
```

```
bun add @cloudflare/shell
```

* [  JavaScript ](#tab-panel-6757)
* [  TypeScript ](#tab-panel-6758)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { DynamicWorkerExecutor } from "@cloudflare/codemode";import { aiTools, createCodeTool } from "@cloudflare/codemode/ai";import { Workspace } from "@cloudflare/shell";import { stateTools } from "@cloudflare/shell/workers";import { weatherTools } from "./tools";
export class Chat extends AIChatAgent {  workspace = new Workspace({ sql: this.ctx.storage.sql });
  codemodeTool() {    return createCodeTool({      tools: [aiTools(weatherTools), stateTools(this.workspace)],      executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),    });  }}
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { DynamicWorkerExecutor } from "@cloudflare/codemode";import { aiTools, createCodeTool } from "@cloudflare/codemode/ai";import { Workspace } from "@cloudflare/shell";import { stateTools } from "@cloudflare/shell/workers";import { weatherTools } from "./tools";
export class Chat extends AIChatAgent<Env> {  workspace = new Workspace({ sql: this.ctx.storage.sql });
  codemodeTool() {    return createCodeTool({      tools: [aiTools(weatherTools), stateTools(this.workspace)],      executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),    });  }}
```

This example exposes AI SDK tools as `codemode.*` and workspace tools as `state.*`.

To assign custom namespaces, pass provider objects instead:

* [  JavaScript ](#tab-panel-6753)
* [  TypeScript ](#tab-panel-6754)

JavaScript

```
const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
const codemode = createCodeTool({  tools: [    { name: "weather", tools: weatherTools },    { name: "notifications", tools: notificationTools },  ],  executor,});
```

TypeScript

```
const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
const codemode = createCodeTool({  tools: [    { name: "weather", tools: weatherTools },    { name: "notifications", tools: notificationTools },  ],  executor,});
```

The generated code can then call `weather.getWeather()` and `notifications.send()`. Provider names must be unique, valid JavaScript identifiers.

## Use AI SDK tools with the durable runtime

Use `ToolSetConnector` or its `toolSetConnector()` convenience function when runs need durable state. The connector adapts an AI SDK `ToolSet` for `createCodemodeRuntime()`. The helper returns `new ToolSetConnector(ctx, options)`.

Create the connector from inside an Agent or another Durable Object:

* [  JavaScript ](#tab-panel-6761)
* [  TypeScript ](#tab-panel-6762)

src/server.js

```
import { AIChatAgent } from "@cloudflare/ai-chat";import {  createCodemodeRuntime,  DynamicWorkerExecutor,} from "@cloudflare/codemode";import { toolSetConnector } from "@cloudflare/codemode/ai";import { convertToModelMessages, streamText } from "ai";import { model } from "./model";import { operationTools } from "./tools";
// Export this manually when the @cloudflare/codemode/vite plugin is not configured.export { CodemodeRuntime } from "@cloudflare/codemode";
export class OperationsAgent extends AIChatAgent {  async onChatMessage() {    const operations = toolSetConnector(this.ctx, {      name: "operations",      instructions: "Use these tools to manage customer requests.",      tools: operationTools,    });
    const runtime = createCodemodeRuntime({      ctx: this.ctx,      executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),      connectors: [operations],    });
    const result = streamText({      model,      messages: await convertToModelMessages(this.messages),      tools: { codemode: runtime.tool() },    });
    return result.toUIMessageStreamResponse();  }}
```

src/server.ts

```
import { AIChatAgent } from "@cloudflare/ai-chat";import {  createCodemodeRuntime,  DynamicWorkerExecutor,} from "@cloudflare/codemode";import { toolSetConnector } from "@cloudflare/codemode/ai";import { convertToModelMessages, streamText } from "ai";import { model } from "./model";import { operationTools } from "./tools";
// Export this manually when the @cloudflare/codemode/vite plugin is not configured.export { CodemodeRuntime } from "@cloudflare/codemode";
export class OperationsAgent extends AIChatAgent<Env> {  async onChatMessage() {    const operations = toolSetConnector(this.ctx, {      name: "operations",      instructions: "Use these tools to manage customer requests.",      tools: operationTools,    });
    const runtime = createCodemodeRuntime({      ctx: this.ctx,      executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),      connectors: [operations],    });
    const result = streamText({      model,      messages: await convertToModelMessages(this.messages),      tools: { codemode: runtime.tool() },    });
    return result.toUIMessageStreamResponse();  }}
```

The example exports `CodemodeRuntime` manually. If you configure the Code Mode Vite plugin as described in [Create a durable Code Mode runtime](https://developers.cloudflare.com/agents/tools/codemode/durable-runtime/), remove that manual export because the plugin adds it.

The connector defaults to the `tools` namespace when `name` is omitted. It excludes tools without an `execute` function from both generated types and sandbox bindings.

The durable runtime adds an execution log, pause and resume behavior, and on-demand connector discovery. The model can find methods with `codemode.search()` and inspect their types with `codemode.describe()`.

## Approval behavior

The two integration patterns handle AI SDK approvals differently.

### `createCodeTool()` approvals

`createCodeTool()` filters out tools where `needsApproval` is `true` or a function. Filtered tools do not appear in generated types and cannot run from sandbox code. A tool with `needsApproval: false` remains available.

This stateless path does not pause execution for AI SDK approval. Use a standard AI SDK tool outside Code Mode if that tool needs the AI SDK approval flow.

### `ToolSetConnector` approvals

`ToolSetConnector` maps AI SDK `needsApproval` to the durable runtime's `requiresApproval` annotation. Calling that tool pauses the run. Your application can inspect pending actions and resume the same execution with `runtime.approve({ executionId })`.

A function-valued `needsApproval` cannot be evaluated before the sandbox supplies arguments. The connector therefore treats the tool as always requiring approval. `needsApproval: false` executes without pausing.

This approval uses the Code Mode runtime's durable pause, approval, and replay flow. It does not use the AI SDK per-call approval flow.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/codemode/ai-sdk/#page","headline":"AI SDK integration · Cloudflare Agents docs","description":"Use AI SDK tools with Code Mode through createCodeTool(), namespaced providers, or ToolSetConnector for durable execution.","url":"https://developers.cloudflare.com/agents/tools/codemode/ai-sdk/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/codemode/","name":"Code Mode"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/codemode/ai-sdk/","name":"AI SDK integration"}}]}
```

---

---
title: Code Mode API reference
description: Reference the public classes, functions, options, runtime methods, connector hooks, and result types exported by Code Mode.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Code Mode API reference

Code Mode publishes six package entry points. Import framework-specific APIs from their matching entry point:

| Entry point                      | Purpose                                                                    |
| -------------------------------- | -------------------------------------------------------------------------- |
| @cloudflare/codemode             | Runtime, connectors, Workers executor, and framework-independent utilities |
| @cloudflare/codemode/ai          | AI SDK tools and connector adapter                                         |
| @cloudflare/codemode/mcp         | Model Context Protocol (MCP) server wrappers                               |
| @cloudflare/codemode/tanstack-ai | TanStack AI tools and adapter                                              |
| @cloudflare/codemode/browser     | Browser tool descriptor and iframe executor                                |
| @cloudflare/codemode/vite        | Vite plugin for connector discovery and Worker exports                     |

## `@cloudflare/codemode`

The main entry point does not require the optional AI SDK, TanStack AI, or Zod peer dependencies to be installed.

### Runtime construction

#### `createCodemodeRuntime()`

TypeScript

```
function createCodemodeRuntime(  options: CreateCodemodeRuntimeOptions,): CodemodeRuntimeHandle;
```

Creates the host-side control plane for a named Code Mode runtime.

`CreateCodemodeRuntimeOptions` has these fields:

| Field           | Type                  | Required | Description                                                                                                |
| --------------- | --------------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
| ctx             | DurableObjectState    | Yes      | Durable Object state that hosts the runtime facet.                                                         |
| connectors      | CodemodeConnector\[\] | Yes      | Connectors exposed as sandbox globals. Connector names must be unique, and codemode is reserved.           |
| executor        | Executor              | Yes      | Sandbox that runs generated code.                                                                          |
| name            | string                | No       | Durable runtime identity. Defaults to "default". Valid characters are letters, digits, \_, \-, and ..      |
| maxExecutions   | number                | No       | Terminal records kept when a new run begins. Defaults to 50. Running and paused executions are not pruned. |
| transformResult | TransformResult       | No       | Reshapes a completed result returned to the model. The audit trail retains the unmodified result.          |

TypeScript

```
interface CodemodeRuntimeHandle {  tool(    options?: CodemodeRuntimeToolOptions,  ): Tool<ProxyToolInput, ProxyToolOutput>;  approve(options: CodemodeApproveOptions): Promise<ProxyToolOutput>;  reject(options: CodemodeRejectOptions): Promise<boolean>;  rollback(options: CodemodeRollbackOptions): Promise<void>;  pending(executionId?: string): Promise<PendingAction[]>;  expirePaused(options?: CodemodeExpireOptions): Promise<string[]>;  executions(limit?: number): Promise<ExecutionState[]>;  deleteExecution(id: string): Promise<boolean>;  pruneExecutions(keep?: number): Promise<number>;  saveSnippet(name: string, options: SaveSnippetOptions): Promise<Snippet>;  snippets(): Promise<Snippet[]>;  deleteSnippet(name: string): Promise<boolean>;}
```

The handle methods have these effects:

| Method                       | Effect                                                                                                                                                                               |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| tool(options?)               | Returns the AI SDK tool given to the model. description replaces the default description. connectorHints adds a one-line hint for each connector when using the default description. |
| approve({ executionId })     | Resumes a paused execution through replay. The result can complete, pause again, or return an error status. It does not revive a non-paused execution.                               |
| reject({ seq, executionId }) | Rejects one pending action and terminates the execution. Returns false if the action is no longer pending. It does not roll back earlier actions.                                    |
| rollback({ executionId })    | Calls available revert functions in reverse call order. Missing connectors and methods without revert remain applied. It attempts later reverts after a failure.                     |
| pending(executionId?)        | Lists pending actions. Without an ID, it combines actions from all paused executions.                                                                                                |
| expirePaused({ maxAgeMs? })  | Terminates stale paused or running executions and returns their IDs. The default age is 24 hours.                                                                                    |
| executions(limit?)           | Returns audit records, newest first.                                                                                                                                                 |
| deleteExecution(id)          | Deletes one audit record. It also disposes resources for a non-terminal execution. Returns whether the record existed.                                                               |
| pruneExecutions(keep?)       | Deletes older terminal records and returns the count deleted. Defaults to keeping 50.                                                                                                |
| saveSnippet(name, options)   | Saves code from options.executionId as a reusable snippet. It accepts any execution status, so applications should verify successful completion first. Replaces the same name.       |
| snippets()                   | Returns saved snippets, ordered by name.                                                                                                                                             |
| deleteSnippet(name)          | Deletes a snippet and returns whether it existed.                                                                                                                                    |

The method option types are:

TypeScript

```
type CodemodeRuntimeToolOptions = {  description?: string;  connectorHints?: Record<string, string>;};
type CodemodeApproveOptions = { executionId: string };type CodemodeRejectOptions = { seq: number; executionId: string };type CodemodeRollbackOptions = { executionId: string };type CodemodeExpireOptions = { maxAgeMs?: number };
```

#### `CodemodeRuntime`

TypeScript

```
class CodemodeRuntime extends DurableObject<unknown> {  constructor(ctx: DurableObjectState, env: unknown);}
```

`CodemodeRuntime` is the durable facet behind the runtime handle. The Vite plugin exports this class from the Worker entry module. Use `createCodemodeRuntime()` for application code instead of constructing the facet directly.

The main entry point also exports these runtime constants:

| Constant                   | Value    | Purpose                                                         |
| -------------------------- | -------- | --------------------------------------------------------------- |
| DEFAULT\_MAX\_EXECUTIONS   | 50       | Default terminal execution retention count                      |
| DEFAULT\_PAUSED\_TTL\_MS   | 86400000 | Default stale execution age in milliseconds (24 hours)          |
| MAX\_DURABLE\_VALUE\_BYTES | 1000000  | Serialized JavaScript string-length limit for one durable value |

### Runtime tool input and output

TypeScript

```
type ProxyToolInput = { code: string };
type ProxyToolOutput =  | {      status: "completed";      executionId: string;      result: unknown;      logs?: string[];    }  | {      status: "paused";      executionId: string;      pending: PendingAction[];    }  | {      status: "error";      executionId: string;      error: string;      logs?: string[];    };
type TransformResult = (result: unknown) => unknown | Promise<unknown>;
```

Sandbox and replay errors use the `error` output variant. They do not throw through the model tool call.

### Execution records

TypeScript

```
type ExecutionStatus =  | "running"  | "paused"  | "completed"  | "error"  | "rejected"  | "rolled_back";
type ExecutionState = {  id: string;  code: string;  status: ExecutionStatus;  log: ToolLogEntry[];  result?: unknown;  error?: string;  logs?: string[];  connectors?: string[];  createdAt: number;  updatedAt: number;};
type ToolLogEntry = {  seq: number;  connector: string;  method: string;  args: unknown;  result?: unknown;  requiresApproval: boolean;  ephemeral?: boolean;  state: "executing" | "applied" | "pending" | "reverted" | "error";};
type PendingAction = {  executionId: string;  seq: number;  connector: string;  method: string;  args: unknown;};
```

`createdAt` and `updatedAt` contain epoch milliseconds. An ephemeral log entry comes from a connector tool with `replay: "reexecute"`. Its result is not stored and the call runs again during replay.

The runtime decision type is:

TypeScript

```
type ToolDecision =  | { kind: "replay"; result: unknown }  | { kind: "execute"; seq: number }  | { kind: "pause"; seq: number };
```

### Sandbox `codemode` API

`runtime.tool()` injects a `codemode` global into generated sandbox code.

TypeScript

```
declare const codemode: {  search(query: string): Promise<SearchOutput>;  describe(target: string): Promise<DescribeOutput>;  step<T>(name: string, fn: () => T | Promise<T>): Promise<T>;  run(name: string, input?: unknown): Promise<unknown>;};
```

The sandbox methods behave as follows:

| Method            | Description                                                                                                       |
| ----------------- | ----------------------------------------------------------------------------------------------------------------- |
| search(query)     | Searches connector methods and saved snippets. Results are ranked and limited to 50.                              |
| describe(target)  | Returns generated TypeScript for a connector, connector.method, or snippet name.                                  |
| step(name, fn)    | Runs a closure once and records its result. Replay returns the recorded result without running the closure again. |
| run(name, input?) | Runs a saved snippet. A missing snippet or recorded connector resolves to an object with an error property.       |

Use `step()` around nondeterministic or side-effectful sandbox work that does not use a connector. Issue connector calls sequentially when an execution can pause. Concurrent calls can reach the replay cursor in a different order.

The discovery output types are:

TypeScript

```
type SearchResult = {  path: string;  connector: string;  method: string;  description?: string;  kind: "method" | "snippet";  score: number;};
type SearchOutput = {  results: SearchResult[];  total: number;  truncated: boolean;};
type DescribeOutput = {  path: string;  description?: string;  types: string;  kind: "connector" | "method" | "snippet";};
```

### Snippet types

TypeScript

```
interface SaveSnippetOptions {  description?: string;  inputSchema?: unknown;  executionId: string;}
interface Snippet {  name: string;  description: string;  code: string;  savedAt: number;  inputSchema?: unknown;  connectors?: string[];}
```

`connectors` records every namespace configured when the source execution started. `savedAt` contains epoch milliseconds. Before calling `saveSnippet()`, verify that the source `ExecutionState.status` is `completed`.

### Executor API

#### `Executor`

TypeScript

```
interface Executor {  execute(    code: string,    providersOrFns:      | ResolvedProvider[]      | Record<string, (...args: unknown[]) => Promise<unknown>>,    options?: ExecuteOptions,  ): Promise<ExecuteResult>;}
```

Custom executors should report failures in `ExecuteResult.error` instead of throwing.

TypeScript

```
interface ExecuteResult {  result: unknown;  error?: string;  logs?: string[];}
interface ResolvedProvider {  name: string;  fns: Record<string, (...args: unknown[]) => Promise<unknown>>;  prelude?: string;}
interface ConnectorBinding {  name: string;  binding: {    callTool(method: string, args: unknown): Promise<unknown>;  };}
interface ExecuteOptions {  connectors?: ConnectorBinding[];}
```

Passing a function record instead of `ResolvedProvider[]` is deprecated. It creates one provider named `codemode`.

#### `DynamicWorkerExecutor`

TypeScript

```
class DynamicWorkerExecutor implements Executor {  constructor(options: DynamicWorkerExecutorOptions);  execute(    code: string,    providersOrFns:      | ResolvedProvider[]      | Record<string, (...args: unknown[]) => Promise<unknown>>,    options?: ExecuteOptions,  ): Promise<ExecuteResult>;}
```

`DynamicWorkerExecutorOptions` has these fields:

| Field          | Type                    | Required | Default | Description                                                                            |
| -------------- | ----------------------- | -------- | ------- | -------------------------------------------------------------------------------------- |
| loader         | WorkerLoader            | Yes      | —       | Worker Loader binding used to create isolated Workers.                                 |
| timeout        | number                  | No       | 60000   | Execution timeout in milliseconds.                                                     |
| globalOutbound | Fetcher \| null         | No       | null    | Outbound network policy. null blocks access. A Fetcher receives all outbound requests. |
| modules        | Record<string, string>  | No       | {}      | Module source keyed by import specifier. The reserved executor.js key is ignored.      |
| bindings       | Record<string, unknown> | No       | {}      | Additional environment bindings injected into each sandbox Worker.                     |

The executor validates provider and connector namespaces. Names must be valid JavaScript identifiers, unique, and must not shadow executor globals.

#### `ToolDispatcher`

TypeScript

```
class ToolDispatcher extends RpcTarget {  constructor(fns: Record<string, (...args: unknown[]) => Promise<unknown>>);  call(name: string, argsJson?: string): Promise<string>;}
```

`ToolDispatcher` is the Workers RPC bridge used by `DynamicWorkerExecutor`. `call()` accepts serialized positional arguments and returns a serialized result or error envelope.

#### `runCode()`

TypeScript

```
function runCode(options: {  code: string;  executor: Executor;  providers: ResolvedProvider[];  connectors?: ConnectorBinding[];}): Promise<{ result: unknown; logs?: string[] }>;
```

Normalizes and executes code. An `ExecuteResult.error` causes `runCode()` to throw an `Error` that includes captured console output.

### Tool providers

TypeScript

```
interface ToolProvider {  name?: string;  tools: ToolDescriptors | ToolSet | SimpleToolRecord;  types?: string;}
```

Tool providers have these fields:

| Field | Description                                                                        |
| ----- | ---------------------------------------------------------------------------------- |
| name  | Sandbox namespace. Defaults to codemode.                                           |
| tools | Tool descriptors, an AI SDK ToolSet, or records containing execute.                |
| types | TypeScript declarations shown to the model. Code Mode generates them when omitted. |

TypeScript

```
function resolveProvider(provider: ToolProvider): ResolvedProvider;
```

The main-entry implementation does not validate inputs against schemas. It excludes tools whose `needsApproval` is `true` or a function. Use runtime connectors for durable approval flows.

### Connector base classes

#### `CodemodeConnector`

TypeScript

```
abstract class CodemodeConnector<  Env = unknown,  Props = unknown,> extends WorkerEntrypoint<Env, Props> {  constructor(ctx: DurableObjectState | ExecutionContext, env: Env);
  abstract name(): string;  protected instructions(): string | undefined;  protected abstract tools(): ConnectorTools | Promise<ConnectorTools>;  protected tool(name: string, tool: ConnectorTool): ConnectorTool;
  describe(): Promise<ConnectorDescription>;  executeTool(    method: string,    args: unknown,    ctx?: ToolExecuteContext,  ): Promise<unknown>;  revertAction(    method: string,    args: unknown,    result: unknown,    ctx?: ToolExecuteContext,  ): Promise<boolean>;  onPassEnd(executionId: string, status: PassEndStatus): Promise<void>;  disposeExecution(    executionId: string,    status: ExecutionEndStatus,  ): Promise<void>;  getTypeScriptTypes(): Promise<string>;}
```

Connector authors implement or override these hooks:

| Hook                                  | Required | Description                                                                                     |
| ------------------------------------- | -------- | ----------------------------------------------------------------------------------------------- |
| name()                                | Yes      | Returns the unique sandbox namespace.                                                           |
| instructions()                        | No       | Returns connector guidance included by describe().                                              |
| tools()                               | Yes      | Returns the connector tool record. Derived connectors implement this hook.                      |
| tool(name, tool)                      | No       | Decorates a resolved tool. Use it to add approval, replay, or revert behavior to derived tools. |
| onPassEnd(executionId, status)        | No       | Releases per-pass resources. Runs after every pass, including a paused pass.                    |
| disposeExecution(executionId, status) | No       | Releases per-execution resources after a terminal transition. It does not run on pause.         |

Lifecycle hooks should be idempotent, should not rely on instance memory, and should not throw. On a terminal pass, `onPassEnd()` runs before `disposeExecution()`.

The base class derives `describe()`, `executeTool()`, `revertAction()`, and `getTypeScriptTypes()` from the tool record. Connector authors do not need to implement these methods.

#### Connector tool types

TypeScript

```
type ConnectorTool = {  description?: string;  inputSchema?: JSONSchema7;  outputSchema?: JSONSchema7;  requiresApproval?: boolean;  replay?: "log" | "reexecute";  execute: (    args: unknown,    ctx?: ToolExecuteContext,  ) => Promise<unknown> | unknown;  revert?: (    args: unknown,    result: unknown,    ctx?: ToolExecuteContext,  ) => Promise<void> | void;};
type ConnectorTools = Record<string, ConnectorTool>;type ToolExecuteContext = { executionId: string };
```

`inputSchema` defaults to an open object. `requiresApproval: true` pauses before execution. `replay: "reexecute"` skips durable result storage and re-executes the call on each resume. These two options cannot be combined.

`revert` provides compensation for `runtime.rollback()`. It can apply to any tool, whether or not the tool requires approval.

#### `McpConnector`

TypeScript

```
abstract class McpConnector<  Env = unknown,  Props = unknown,> extends CodemodeConnector<Env, Props> {  protected abstract createConnection():    | McpConnectionLike    | Promise<McpConnectionLike>;  protected toolName(tool: McpTool): string;}
```

`McpConnector` converts each MCP tool into a connector method. `toolName()` defaults to `sanitizeToolName(tool.name)`. Override it to resolve naming collisions.

TypeScript

```
interface McpConnectionLike {  name?: string;  client: Pick<Client, "callTool">;  instructions?: string;  tools?: McpTool[];  fetchTools?: () => Promise<McpTool[]>;}
```

The connector uses `tools` when that array is non-empty. Otherwise, it calls `fetchTools()` when provided. MCP error results become thrown connector errors. Structured content is returned before text content.

#### `OpenApiConnector`

TypeScript

```
abstract class OpenApiConnector<  Env = unknown,  Props = unknown,> extends CodemodeConnector<Env, Props> {  protected abstract spec():    | Record<string, unknown>    | Promise<Record<string, unknown>>;  protected abstract request(options: OpenApiRequestOptions): Promise<unknown>;  protected exposeSpec(): boolean;}
```

`OpenApiConnector` creates one method per OpenAPI operation. It uses a sanitized `operationId` when present, then falls back to a name based on the HTTP method and path. Duplicate operations and names reserved for `request` or `spec` are skipped.

Every OpenAPI connector exposes a low-level `request` method. `exposeSpec()` defaults to `false`. Return `true` to also expose `spec`.

TypeScript

```
type OpenApiRequestOptions = {  path: string;  method?: string;  params?: Record<string, unknown>;  body?: unknown;  headers?: Record<string, string>;};
```

Derived operation tools substitute path parameters. They pass query values as `params`, header values as `headers`, and JSON request data as `body`.

#### Connector lifecycle and description types

TypeScript

```
type ExecutionEndStatus = "completed" | "error" | "rejected" | "rolled_back";
type PassEndStatus = ExecutionEndStatus | "paused";
type ToolAnnotations = {  requiresApproval?: boolean;  replay?: "log" | "reexecute";};
type ConnectorDescription = {  name: string;  instructions?: string;  descriptors: JsonSchemaToolDescriptors;  annotations?: Record<string, ToolAnnotations>;};
```

### JSON Schema utilities

TypeScript

```
interface JsonSchemaToolDescriptor {  description?: string;  inputSchema: JSONSchema7;  outputSchema?: JSONSchema7;}
type JsonSchemaToolDescriptors = Record<string, JsonSchemaToolDescriptor>;
function generateTypesFromJsonSchema(tools: JsonSchemaToolDescriptors): string;
function jsonSchemaToType(schema: JSONSchema7, typeName: string): string;
```

`generateTypesFromJsonSchema()` returns declarations for a `codemode` namespace. Tool names are sanitized before declarations are generated. Unsupported schemas degrade to `unknown` instead of causing generation to fail.

### Code and output utilities

The main entry point provides these code and result utilities:

| Function         | Signature                                              | Behavior                                                                                                                      |
| ---------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| sanitizeToolName | (name: string) => string                               | Replaces common separators, removes invalid characters, prefixes digit-leading names, and suffixes JavaScript reserved words. |
| normalizeCode    | (code: string) => string                               | Converts common model output forms into an async arrow function. It also removes supported Markdown fences.                   |
| truncateResponse | (text: string, options?: TruncateOptions) => string    | Truncates text to a character budget and appends a size marker.                                                               |
| truncateResult   | (value: unknown, options?: TruncateOptions) => unknown | Preserves small structured values. Oversized serializable values become truncated JSON text.                                  |

TypeScript

```
type TruncateOptions = {  maxChars?: number;  maxTokens?: number;};
```

The default budget is `6000` estimated tokens at four characters per token. `maxChars` overrides the derived character budget.

## `@cloudflare/codemode/ai`

This entry point requires the `ai` and `zod` peer dependencies.

### `createCodeTool()`

TypeScript

```
function createCodeTool(  options: CreateCodeToolOptions,): Tool<CodeInput, CodeOutput>;
interface CreateCodeToolOptions {  tools: ToolProviderTools | ToolProvider[];  executor: Executor;  description?: string;}
type CodeInput = { code: string };type CodeOutput = { result: unknown; logs?: string[] };
```

`description` can contain `{{types}}`. Code Mode replaces that token with generated declarations. A raw tool record becomes one provider named `codemode`. An array accepts multiple provider namespaces.

Tools whose `needsApproval` is `true` or a function are excluded. This API does not pause. Use `createCodemodeRuntime()` and connectors for durable approval handling.

### AI SDK provider utilities

The AI SDK entry point provides these tool-provider utilities:

| Export          | Signature                                                         | Description                                                                                                             |
| --------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| aiTools         | (tools: ToolDescriptors \| ToolSet) => ToolProvider               | Wraps AI SDK tools in the default provider.                                                                             |
| generateTypes   | (tools: ToolDescriptors \| ToolSet, namespace?: string) => string | Generates declarations from AI SDK or Zod schemas. The namespace defaults to codemode.                                  |
| resolveProvider | (provider: ToolProvider) => ResolvedProvider                      | Filters approval-gated tools, validates input with AI SDK asSchema() when available, and extracts executable functions. |

TypeScript

```
interface ToolDescriptor {  description?: string;  inputSchema: ZodType;  outputSchema?: ZodType;  execute?: (args: unknown) => Promise<unknown>;}
type ToolDescriptors = Record<string, ToolDescriptor>;
```

### `ToolSetConnector`

TypeScript

```
class ToolSetConnector extends CodemodeConnector {  constructor(    ctx: DurableObjectState | ExecutionContext,    options: ToolSetConnectorOptions,  );}
function toolSetConnector(  ctx: DurableObjectState | ExecutionContext,  options: ToolSetConnectorOptions,): ToolSetConnector;
interface ToolSetConnectorOptions {  name?: string;  instructions?: string;  tools: ToolSet;}
```

The namespace defaults to `tools`. The connector excludes tools without an `execute` function. `needsApproval: true` and function-valued `needsApproval` map to durable connector approval. `needsApproval: false` executes without approval. AI SDK schemas validate input before execution.

## `@cloudflare/codemode/mcp`

This entry point requires the MCP SDK and Zod peer dependencies.

### `codeMcpServer()`

TypeScript

```
interface CodeMcpServerOptions {  server: McpServer;  executor: Executor;  description?: string;}
function codeMcpServer(options: CodeMcpServerOptions): Promise<McpServer>;
```

Wraps an existing MCP server with one `code` tool. The wrapper connects to the source server through an in-memory transport, discovers its tools, and exposes those tools as methods on `codemode` inside the executor.

A custom description can contain `{{types}}`, which the wrapper replaces with generated TypeScript declarations. It can also contain `{{example}}`, which the wrapper replaces with an example call based on the first upstream MCP tool. Returned MCP values are unwrapped in this order: compatibility `toolResult`, MCP errors, `structuredContent`, all-text content, then the original mixed-content result.

### `openApiMcpServer()`

TypeScript

```
interface OpenApiMcpServerOptions {  spec: Record<string, unknown>;  executor: Executor;  request: (options: RequestOptions) => Promise<unknown>;  name?: string;  version?: string;  description?: string;}
interface RequestOptions {  method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";  path: string;  query?: Record<string, string | number | boolean | undefined>;  body?: unknown;  contentType?: string;  rawBody?: boolean;}
function openApiMcpServer(options: OpenApiMcpServerOptions): McpServer;
```

Creates an MCP server with two tools:

| MCP tool | Sandbox API                                   | Purpose                                                                                                       |
| -------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| search   | codemode.spec()                               | Runs code against the OpenAPI document. Local $ref values are resolved before the code receives the document. |
| execute  | codemode.spec() and codemode.request(options) | Runs code that can inspect the document and call the host-provided request function.                          |

`name` defaults to `openapi`. `version` defaults to `1.0.0`. The host request function keeps credentials outside the sandbox. Text responses are limited to approximately 6,000 tokens and include a truncation marker when clipped.

The `search` and `execute` tool descriptions use fixed example snippets. Unlike `codeMcpServer()`, this function does not support `{{types}}` or `{{example}}` placeholders. An optional `description` is appended to the `execute` tool description.

## `@cloudflare/codemode/tanstack-ai`

This entry point requires the `@tanstack/ai` and `zod` peer dependencies.

### `createCodeTool()`

TypeScript

```
function createCodeTool(options: CreateCodeToolOptions): ServerTool;
```

The options, `CodeInput`, and `CodeOutput` match the `/ai` entry point. The returned `ServerTool` can be passed to TanStack AI `chat()`.

### TanStack AI provider utilities

The TanStack AI entry point provides these tool-provider utilities:

| Export             | Signature                                                          | Description                                                                                                                  |
| ------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| tanstackTools      | (tools: TanStackTool\[\], name?: string) => ToolProvider           | Wraps TanStack AI tools in a provider. Only tools with an execute function are callable. The namespace defaults to codemode. |
| generateTypes      | (tools: TanStackTool\[\], namespace?: string) => string            | Converts supported TanStack AI schemas to JSON Schema, then generates declarations.                                          |
| resolveProvider    | (provider: ToolProvider) => ResolvedProvider                       | Resolves a framework-independent provider without schema validation.                                                         |
| normalizeProviders | (tools: ToolProviderTools \| ToolProvider\[\]) => ToolProvider\[\] | Converts raw tools into a one-element provider array.                                                                        |

This entry point also exports `DEFAULT_DESCRIPTION`. `tanstackTools()` excludes tools with `needsApproval: true` or a function-valued `needsApproval`. Tools with `needsApproval: false` remain callable.

## `@cloudflare/codemode/browser`

The browser entry point uses browser APIs and plain JSON Schema. It does not require the AI SDK or Zod.

### `createBrowserCodeTool()`

TypeScript

```
function createBrowserCodeTool(  options: CreateBrowserCodeToolOptions,): BrowserCodeToolDescriptor;
interface CreateBrowserCodeToolOptions {  tools:    | JsonSchemaExecutableToolDescriptor[]    | JsonSchemaExecutableToolDescriptors;  executor?: Executor;  description?: string;}
```

Array-form tools must include `name`. Object-form tools use each record key as the name. The executor defaults to a new `IframeSandboxExecutor`.

The `tools` option also accepts descriptors with `needsApproval?: boolean | ((...args: unknown[]) => unknown)`. Tools with `needsApproval: true` or a function-valued `needsApproval` are excluded. Tools with `needsApproval: false` remain callable. JSON Schema contributes model-facing declarations but does not perform runtime validation.

TypeScript

```
interface JsonSchemaExecutableToolDescriptor extends JsonSchemaToolDescriptor {  name?: string;  execute: (args: Record<string, unknown>) => Promise<unknown>;}
type JsonSchemaExecutableToolDescriptors = Record<  string,  JsonSchemaExecutableToolDescriptor>;
```

The returned descriptor has this shape:

TypeScript

```
interface BrowserCodeToolDescriptor {  name: string;  description: string;  inputSchema: {    type: "object";    properties: {      code: { type: "string"; description: string };    };    required: ["code"];  };  outputSchema: {    type: "object";    properties: {      result: { description: string };      logs: {        type: "array";        items: { type: "string" };        description: string;      };    };    required: ["result"];  };  execute(args: CodeInput): Promise<CodeOutput>;}
```

### `IframeSandboxExecutor`

TypeScript

```
class IframeSandboxExecutor implements Executor {  constructor(options?: IframeSandboxExecutorOptions);  execute(    code: string,    providersOrFns:      | ResolvedProvider[]      | Record<string, (...args: unknown[]) => Promise<unknown>>,  ): Promise<ExecuteResult>;}
interface IframeSandboxExecutorOptions {  timeout?: number;  csp?: string;}
```

The iframe executor accepts these options:

| Field   | Default                                                       | Description                                                                                                      |
| ------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| timeout | 30000                                                         | Maximum execution time in milliseconds. It cannot preempt a synchronous loop that blocks the browser event loop. |
| csp     | default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; | Content Security Policy applied to the sandbox iframe document.                                                  |

Each execution creates a hidden iframe with `sandbox="allow-scripts"`. Tool calls cross the iframe boundary through nonce-scoped `postMessage` messages. The iframe is removed after success, error, or timeout.

This entry point also exports the framework-independent `Executor`, `ExecuteResult`, and `ResolvedProvider` types. It re-exports `JsonSchemaToolDescriptor` and `JsonSchemaToolDescriptors`.

## `@cloudflare/codemode/vite`

The Vite entry point has one default export:

TypeScript

```
function codemodeVitePlugin(): Plugin;
```

The plugin appends `export { CodemodeRuntime } from "@cloudflare/codemode"` to the Worker entry module (`src/server.ts`, `src/index.ts`, or `src/worker.ts`). This makes the runtime facet available as `ctx.exports.CodemodeRuntime`, which `createCodemodeRuntime()` requires.

The plugin leaves the entry module unchanged if it already exports `CodemodeRuntime`. Connector classes need no special file name or import syntax — import them normally and pass instances to the runtime.

Without the plugin, add the export manually:

TypeScript

```
export { CodemodeRuntime } from "@cloudflare/codemode";
```

A connector import can target one connector file or a directory. A directory import re-exports every matching connector file under that directory.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/codemode/api-reference/#page","headline":"Code Mode API reference · Cloudflare Agents docs","description":"Reference the public classes, functions, options, runtime methods, connector hooks, and result types exported by Code Mode.","url":"https://developers.cloudflare.com/agents/tools/codemode/api-reference/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/codemode/","name":"Code Mode"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/codemode/api-reference/","name":"Code Mode API reference"}}]}
```

---

---
title: Browser integration
description: Run model-generated code against browser-owned tools with the Code Mode iframe executor and an Agent chat UI.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Browser integration

Use `@cloudflare/codemode/browser` when your browser owns the tools that the model must orchestrate. For example, these tools might read page state, access browser APIs, or update data held by your application.

Code Mode is useful when the model must call several client tools with loops, conditions, or intermediate results. For a single browser action, use a standard client-side tool instead.

Code Mode presents those tools to the model as typed functions. The model writes one JavaScript async arrow function that can call several tools, combine their results, and apply control flow. `IframeSandboxExecutor` runs that generated code in a sandboxed iframe on the page.

This integration does not give an agent control of a remote browser. To inspect websites, capture screenshots, or automate pages with the Chrome DevTools Protocol (CDP), refer to [Browser tools](https://developers.cloudflare.com/agents/tools/browser/).

## Install Code Mode

Install the package in your client application:

 npm  yarn  pnpm  bun 

```
npm i @cloudflare/codemode
```

```
yarn add @cloudflare/codemode
```

```
pnpm add @cloudflare/codemode
```

```
bun add @cloudflare/codemode
```

The `@cloudflare/codemode/browser` entry point uses JSON Schema and browser APIs. It does not require the AI SDK or Zod peer dependencies used by `@cloudflare/codemode/ai`.

## Add Code Mode to an Agent chat UI

The browser creates the Code Mode tool and registers it as a dynamic client tool. The Agent receives the tool schema, but the tool implementation remains in the browser.

1. Define the browser-owned tools with JSON Schema and an `execute` function.

  * [  JavaScript ](#tab-panel-6765)
  * [  TypeScript ](#tab-panel-6766)  
src/browser-tools.js  
```  
export const browserTools = {  getPageInfo: {    description: "Get information about the current browser page",    inputSchema: {      type: "object",      properties: {},      required: [],    },    execute: async () => ({      title: document.title,      url: window.location.href,    }),  },  getSelectionText: {    description: "Get the user's current text selection",    inputSchema: {      type: "object",      properties: {},      required: [],    },    execute: async () => ({      text: window.getSelection()?.toString() ?? "",    }),  },};  
```  
src/browser-tools.ts  
```  
import type { JsonSchemaExecutableToolDescriptors } from "@cloudflare/codemode/browser";  
export const browserTools: JsonSchemaExecutableToolDescriptors = {  getPageInfo: {    description: "Get information about the current browser page",    inputSchema: {      type: "object",      properties: {},      required: []    },    execute: async () => ({      title: document.title,      url: window.location.href    })  },  getSelectionText: {    description: "Get the user's current text selection",    inputSchema: {      type: "object",      properties: {},      required: []    },    execute: async () => ({      text: window.getSelection()?.toString() ?? ""    })  }};  
```  
JSON Schema supplies the types shown to the model. `createBrowserCodeTool()` does not use the schema to validate arguments at runtime. Validate untrusted inputs inside each `execute` function when required.
2. Create the Code Mode descriptor with an iframe executor.

  * [  JavaScript ](#tab-panel-6763)
  * [  TypeScript ](#tab-panel-6764)  
src/codemode-tool.js  
```  
import {  IframeSandboxExecutor,  createBrowserCodeTool,} from "@cloudflare/codemode/browser";import { browserTools } from "./browser-tools";  
export const codemodeTool = createBrowserCodeTool({  tools: browserTools,  executor: new IframeSandboxExecutor(),});  
```  
src/codemode-tool.ts  
```  
import {  IframeSandboxExecutor,  createBrowserCodeTool} from "@cloudflare/codemode/browser";import { browserTools } from "./browser-tools";  
export const codemodeTool = createBrowserCodeTool({  tools: browserTools,  executor: new IframeSandboxExecutor()});  
```  
`createBrowserCodeTool()` returns a plain descriptor named `codemode`. Its description includes generated TypeScript definitions for the browser tools. Its input contains the model-generated JavaScript in a `code` property.  
The `executor` option is optional. When omitted, `createBrowserCodeTool()` creates an `IframeSandboxExecutor` with default settings.
3. Register the descriptor with `useAgentChat()` and execute client tool calls.

  * [  JavaScript ](#tab-panel-6769)
  * [  TypeScript ](#tab-panel-6770)  
src/client.jsx  
```  
import { useAgentChat } from "@cloudflare/ai-chat/react";import { useAgent } from "agents/react";import { useMemo } from "react";import { codemodeTool } from "./codemode-tool";  
function BrowserCodeModeChat() {  const agent = useAgent({ agent: "browser-codemode" });  
  const tools = useMemo(    () => ({      codemode: {        description: codemodeTool.description,        parameters: codemodeTool.inputSchema,        execute: (input) => codemodeTool.execute(input),      },    }),    [],  );  
  const { messages, sendMessage } = useAgentChat({    agent,    tools,    onToolCall: async ({ toolCall, addToolOutput }) => {      const tool = tools[toolCall.toolName];      if (!tool?.execute) return;  
      try {        const output = await tool.execute(toolCall.input);        addToolOutput({          toolCallId: toolCall.toolCallId,          output,        });      } catch (error) {        addToolOutput({          toolCallId: toolCall.toolCallId,          state: "output-error",          errorText: error instanceof Error ? error.message : String(error),        });      }    },  });  
  // Render messages and call sendMessage() from your chat UI.}  
```  
src/client.tsx  
```  
import { useAgentChat, type AITool } from "@cloudflare/ai-chat/react";import { useAgent } from "agents/react";import { useMemo } from "react";import { codemodeTool } from "./codemode-tool";  
function BrowserCodeModeChat() {  const agent = useAgent({ agent: "browser-codemode" });  
  const tools = useMemo<Record<string, AITool>>(    () => ({      codemode: {        description: codemodeTool.description,        parameters: codemodeTool.inputSchema,        execute: (input) =>          codemodeTool.execute(input as { code: string })      }    }),    []  );  
  const { messages, sendMessage } = useAgentChat({    agent,    tools,    onToolCall: async ({ toolCall, addToolOutput }) => {      const tool = tools[toolCall.toolName];      if (!tool?.execute) return;  
      try {        const output = await tool.execute(toolCall.input);        addToolOutput({          toolCallId: toolCall.toolCallId,          output        });      } catch (error) {        addToolOutput({          toolCallId: toolCall.toolCallId,          state: "output-error",          errorText: error instanceof Error ? error.message : String(error)        });      }    }  });  
  // Render messages and call sendMessage() from your chat UI.}  
```  
`useAgentChat()` sends the registered client tool schema to the Agent. When the model calls `codemode`, `onToolCall` executes the descriptor in the browser and adds its output to the conversation.
4. On the Agent, convert the client schemas into model tools.

  * [  JavaScript ](#tab-panel-6767)
  * [  TypeScript ](#tab-panel-6768)  
src/server.js  
```  
import { AIChatAgent, createToolsFromClientSchemas } from "@cloudflare/ai-chat";import { convertToModelMessages, stepCountIs, streamText } from "ai";import { createWorkersAI } from "workers-ai-provider";  
export class BrowserCodemode extends AIChatAgent {  async onChatMessage(_onFinish, options) {    const workersai = createWorkersAI({ binding: this.env.AI });  
    const result = streamText({      model: workersai("@cf/moonshotai/kimi-k2.7-code"),      system:        "Use the codemode tool to write JavaScript that calls browser-provided tools.",      messages: await convertToModelMessages(this.messages),      tools: createToolsFromClientSchemas(options?.clientTools),      stopWhen: stepCountIs(10),    });  
    return result.toUIMessageStreamResponse();  }}  
```  
src/server.ts  
```  
import { AIChatAgent, createToolsFromClientSchemas } from "@cloudflare/ai-chat";import { convertToModelMessages, stepCountIs, streamText } from "ai";import { createWorkersAI } from "workers-ai-provider";  
export class BrowserCodemode extends AIChatAgent<Env> {  async onChatMessage(    _onFinish?: unknown,    options?: {      clientTools?: Parameters<typeof createToolsFromClientSchemas>[0];    }  ) {    const workersai = createWorkersAI({ binding: this.env.AI });  
    const result = streamText({      model: workersai("@cf/moonshotai/kimi-k2.7-code"),      system:        "Use the codemode tool to write JavaScript that calls browser-provided tools.",      messages: await convertToModelMessages(this.messages),      tools: createToolsFromClientSchemas(options?.clientTools),      stopWhen: stepCountIs(10)    });  
    return result.toUIMessageStreamResponse();  }}  
```  
The Agent advertises the client-provided schema to the model. It does not run the generated code or the browser tool implementations.

If your browser tool set changes at runtime, create a new Code Mode descriptor and register the updated descriptor with your client tool layer.

## Iframe execution and security

`IframeSandboxExecutor` creates a hidden iframe for each execution. The iframe uses `sandbox="allow-scripts"` and receives the generated code through `postMessage`. Tool calls return to the parent page, which runs the matching browser-owned `execute` function.

Messages are scoped to the current iframe and an execution nonce. The executor removes the iframe and message listener after completion, failure, or timeout.

The executor accepts these options:

| Option  | Type   | Default                                                       | Behavior                                                        |
| ------- | ------ | ------------------------------------------------------------- | --------------------------------------------------------------- |
| timeout | number | 30000                                                         | Ends an execution after the specified number of milliseconds.   |
| csp     | string | default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; | Sets the Content Security Policy (CSP) for the iframe document. |

The default CSP blocks resources except the inline and evaluated scripts required to execute generated code. Pass a custom policy only when your generated code needs additional iframe capabilities.

Relaxing directives such as `connect-src`, `img-src`, or `form-action` can let generated iframe code communicate with external systems. That code could expose values returned by browser tools. Keep outbound destinations narrow, and do not place secrets in tool results. Browser-owned tools execute separately in the parent page with the capabilities their implementations provide.

Warning

The timeout cannot interrupt a tight synchronous loop such as `while (true) {}`. That code blocks the browser event loop, so the timeout callback cannot run. Browser-owned tools also execute in the parent page and retain the capabilities you give them.

## Approval constraints

`createBrowserCodeTool()` excludes any tool whose `needsApproval` value is `true` or a function. Code Mode does not pause iframe execution to request approval for those tools.

Keep approval-gated actions outside the Code Mode descriptor. Register them as standard tools and use the [useAgentChat() approval flow](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/#tool-approval-human-in-the-loop) instead.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/codemode/browser/#page","headline":"Browser integration · Cloudflare Agents docs","description":"Run model-generated code against browser-owned tools with the Code Mode iframe executor and an Agent chat UI.","url":"https://developers.cloudflare.com/agents/tools/codemode/browser/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/codemode/","name":"Code Mode"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/codemode/browser/","name":"Browser integration"}}]}
```

---

---
title: Create a durable Code Mode runtime
description: Create a Code Mode runtime with connectors, durable approvals, rollback, execution history, and reusable snippets.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Create a durable Code Mode runtime

This guide adds a durable Code Mode runtime to an Agents SDK application. The runtime stores execution history, pending approvals, and snippets across Durable Object hibernation.

Warning

Code Mode is experimental and may introduce breaking changes. Use caution in production.

## Prerequisites

You need an existing Agents SDK application that uses `AIChatAgent`, Vite, and the AI SDK.

## Integrate Code Mode

1. Install the Code Mode package:  
 npm  yarn  pnpm  bun  
```  
npm i @cloudflare/codemode  
```  
```  
yarn add @cloudflare/codemode  
```  
```  
pnpm add @cloudflare/codemode  
```  
```  
bun add @cloudflare/codemode  
```
2. Add a Worker Loader binding. `DynamicWorkerExecutor` uses this binding to run model-generated code in isolated Workers:

  * [  wrangler.jsonc ](#tab-panel-6771)
  * [  wrangler.toml ](#tab-panel-6772)  
JSONC  
```  
{  "$schema": "./node_modules/wrangler/config-schema.json",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": [    "nodejs_compat"  ],  "worker_loaders": [    {      "binding": "LOADER"    }  ]}  
```  
TOML  
```  
# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = ["nodejs_compat"]  
[[worker_loaders]]binding = "LOADER"  
```
3. Add the Agents and Code Mode plugins to `vite.config.ts`:

  * [  JavaScript ](#tab-panel-6773)
  * [  TypeScript ](#tab-panel-6774)  
vite.config.js  
```  
import { cloudflare } from "@cloudflare/vite-plugin";import codemode from "@cloudflare/codemode/vite";import agents from "agents/vite";import { defineConfig } from "vite";  
export default defineConfig({  plugins: [agents(), codemode(), cloudflare()],});  
```  
vite.config.ts  
```  
import { cloudflare } from "@cloudflare/vite-plugin";import codemode from "@cloudflare/codemode/vite";import agents from "agents/vite";import { defineConfig } from "vite";  
export default defineConfig({  plugins: [agents(), codemode(), cloudflare()],});  
```  
The plugin exports the `CodemodeRuntime` facet class from your Worker entry module. The runtime stores execution state in a Durable Object facet, and the Workers runtime requires facet classes to be available through `ctx.exports`. If you do not use the plugin, add the export manually:  
TypeScript  
```  
export { CodemodeRuntime } from "@cloudflare/codemode";  
```
4. Create a connector. Connectors are plain classes — they need no special file name or import syntax. This example stores notes in the Agent's Durable Object storage:

  * [  JavaScript ](#tab-panel-6775)
  * [  TypeScript ](#tab-panel-6776)  
src/notes-connector.js  
```  
import { CodemodeConnector } from "@cloudflare/codemode";  
export class NotesConnector extends CodemodeConnector {  storage;  
  constructor(ctx, env) {    super(ctx, env);    this.storage = ctx.storage;  }  
  name() {    return "notes";  }  
  instructions() {    return "Use this connector to list and create saved notes.";  }  
  tools() {    return {      listNotes: {        description: "List saved notes.",        execute: async () => (await this.storage.get("notes")) ?? [],      },      createNote: {        description: "Create a saved note.",        inputSchema: {          type: "object",          properties: { text: { type: "string" } },          required: ["text"],        },        requiresApproval: true,        execute: async (input) => {          const { text } = input;          const note = { id: crypto.randomUUID(), text };          const notes = (await this.storage.get("notes")) ?? [];          await this.storage.put("notes", [...notes, note]);          return note;        },        revert: async (_input, result) => {          const { id } = result;          const notes = (await this.storage.get("notes")) ?? [];          await this.storage.put(            "notes",            notes.filter((note) => note.id !== id),          );        },      },    };  }}  
```  
src/notes-connector.ts  
```  
import {  CodemodeConnector,  type ConnectorTools,} from "@cloudflare/codemode";  
type Note = { id: string; text: string };  
export class NotesConnector extends CodemodeConnector<Env> {  private storage: DurableObjectStorage;  
  constructor(ctx: DurableObjectState, env: Env) {    super(ctx, env);    this.storage = ctx.storage;  }  
  override name() {    return "notes";  }  
  protected override instructions() {    return "Use this connector to list and create saved notes.";  }  
  protected override tools(): ConnectorTools {    return {      listNotes: {        description: "List saved notes.",        execute: async () =>          (await this.storage.get<Note[]>("notes")) ?? [],      },      createNote: {        description: "Create a saved note.",        inputSchema: {          type: "object",          properties: { text: { type: "string" } },          required: ["text"],        },        requiresApproval: true,        execute: async (input) => {          const { text } = input as { text: string };          const note = { id: crypto.randomUUID(), text };          const notes = (await this.storage.get<Note[]>("notes")) ?? [];          await this.storage.put("notes", [...notes, note]);          return note;        },        revert: async (_input, result) => {          const { id } = result as Note;          const notes = (await this.storage.get<Note[]>("notes")) ?? [];          await this.storage.put(            "notes",            notes.filter((note) => note.id !== id),          );        },      },    };  }}  
```  
The `name()` result becomes the sandbox global, in this case `notes`. `requiresApproval: true` pauses before `createNote` executes. The optional `revert` function lets `runtime.rollback()` compensate for an applied call.  
Use `McpConnector` for MCP tools or `OpenApiConnector` for OpenAPI operations. For MCP-specific setup, refer to [Use MCP tools with Code Mode](https://developers.cloudflare.com/agents/tools/codemode/mcp/).
5. Import the connector and create a runtime in your Agent:

  * [  JavaScript ](#tab-panel-6777)
  * [  TypeScript ](#tab-panel-6778)  
src/server.js  
```  
import { AIChatAgent } from "@cloudflare/ai-chat";import {  createCodemodeRuntime,  DynamicWorkerExecutor,} from "@cloudflare/codemode";import { callable } from "agents";import { convertToModelMessages, stepCountIs, streamText } from "ai";import { NotesConnector } from "./notes-connector";import { model } from "./model";  
export class Chat extends AIChatAgent {  #runtime() {    return createCodemodeRuntime({      ctx: this.ctx,      executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),      connectors: [new NotesConnector(this.ctx, this.env)],    });  }  
  async onChatMessage() {    const result = streamText({      model,      messages: await convertToModelMessages(this.messages),      tools: { codemode: this.#runtime().tool() },      stopWhen: stepCountIs(10),    });  
    return result.toUIMessageStreamResponse();  }  
  @callable()  async pendingApprovals() {    return this.#runtime().pending();  }  
  @callable()  async approveExecution(executionId) {    return this.#runtime().approve({ executionId });  }  
  @callable()  async rejectExecution(executionId, seq) {    return this.#runtime().reject({ executionId, seq });  }  
  @callable()  async rollbackExecution(executionId) {    await this.#runtime().rollback({ executionId });  }  
  @callable()  async executionHistory() {    return this.#runtime().executions(20);  }  
  @callable()  async saveSnippet(name, description, executionId) {    const runtime = this.#runtime();    const execution = (await runtime.executions()).find(      (item) => item.id === executionId,    );    if (execution?.status !== "completed") {      throw new Error("Only completed executions can be saved as snippets.");    }  
    return runtime.saveSnippet(name, { description, executionId });  }  
  @callable()  async snippets() {    return this.#runtime().snippets();  }}  
```  
src/server.ts  
```  
import { AIChatAgent } from "@cloudflare/ai-chat";import {  createCodemodeRuntime,  DynamicWorkerExecutor,  type CodemodeRuntimeHandle,  type ExecutionState,  type PendingAction,  type Snippet,} from "@cloudflare/codemode";import { callable } from "agents";import { convertToModelMessages, stepCountIs, streamText } from "ai";import { NotesConnector } from "./notes-connector";import { model } from "./model";  
export class Chat extends AIChatAgent<Env> {  #runtime(): CodemodeRuntimeHandle {    return createCodemodeRuntime({      ctx: this.ctx,      executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),      connectors: [new NotesConnector(this.ctx, this.env)],    });  }  
  async onChatMessage() {    const result = streamText({      model,      messages: await convertToModelMessages(this.messages),      tools: { codemode: this.#runtime().tool() },      stopWhen: stepCountIs(10),    });  
    return result.toUIMessageStreamResponse();  }  
  @callable()  async pendingApprovals(): Promise<PendingAction[]> {    return this.#runtime().pending();  }  
  @callable()  async approveExecution(executionId: string) {    return this.#runtime().approve({ executionId });  }  
  @callable()  async rejectExecution(executionId: string, seq: number): Promise<boolean> {    return this.#runtime().reject({ executionId, seq });  }  
  @callable()  async rollbackExecution(executionId: string): Promise<void> {    await this.#runtime().rollback({ executionId });  }  
  @callable()  async executionHistory(): Promise<ExecutionState[]> {    return this.#runtime().executions(20);  }  
  @callable()  async saveSnippet(    name: string,    description: string,    executionId: string,  ): Promise<Snippet> {    const runtime = this.#runtime();    const execution = (await runtime.executions()).find(      (item) => item.id === executionId,    );    if (execution?.status !== "completed") {      throw new Error("Only completed executions can be saved as snippets.");    }  
    return runtime.saveSnippet(name, { description, executionId });  }  
  @callable()  async snippets(): Promise<Snippet[]> {    return this.#runtime().snippets();  }}  
```  
Replace the `model` import with the existing model setup in your application.

## Verify the integration

Ask the model to list saved notes. The model receives one `codemode` tool and can discover connector methods inside the sandbox:

JavaScript

```
async () => {  const matches = await codemode.search("list saved notes");  const docs = await codemode.describe(matches.results[0].path);  const savedNotes = await notes.listNotes();
  return { docs, savedNotes };};
```

When the model calls `notes.createNote()`, the execution pauses. Use `pendingApprovals()` to show the pending action. Pass its `executionId` to `approveExecution()`, or pass both `executionId` and `seq` to `rejectExecution()`.

Approval resumes the same script through replay. Completed calls return recorded results instead of running again. Rejection ends the paused execution without undoing earlier actions.

Call `rollbackExecution()` to compensate for applied calls whose currently configured connector provides `revert`. Save only completed executions as snippets.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/codemode/durable-runtime/#page","headline":"Create a durable Code Mode runtime · Cloudflare Agents docs","description":"Create a Code Mode runtime with connectors, durable approvals, rollback, execution history, and reusable snippets.","url":"https://developers.cloudflare.com/agents/tools/codemode/durable-runtime/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/codemode/","name":"Code Mode"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/codemode/durable-runtime/","name":"Create a durable Code Mode runtime"}}]}
```

---

---
title: How Code Mode works
description: Learn how Code Mode isolates generated code and makes approvals, replay, rollback, and reuse durable.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# How Code Mode works

Code Mode is a pattern where a model writes code to compose tools. The `@cloudflare/codemode` package implements that pattern with an isolated executor, service connectors, and a durable runtime.

These parts have separate responsibilities. The executor runs code but stores no state. Connectors provide capabilities but do not manage replay. The runtime records execution and controls approvals, replay, rollback, and reuse.

Warning

Code Mode is experimental and may have breaking changes.

## What the model sees

In the standard setup, the model receives one outer tool named `codemode`. That tool accepts one field and returns a durable execution outcome:

TypeScript

```
type CodeModeInput = {  code: string;};
type PendingAction = {  executionId: string;  seq: number;  connector: string;  method: string;  args: unknown;};
type CodeModeOutput =  | { status: "completed"; executionId: string; result: unknown; logs?: string[] }  | { status: "paused"; executionId: string; pending: PendingAction[] }  | { status: "error"; executionId: string; error: string; logs?: string[] };
```

Its description tells the model to write a JavaScript async arrow function. The description lists the configured connector namespace names, such as `github` or `stripe`, but it does not include every connector method and schema.

The model can use one Code Mode execution to discover relevant methods, then use the returned paths and types in its next execution. This keeps the complete tool catalog out of the initial model context.

### Platform SDK

Inside the sandbox, the `codemode` global provides the platform-level SDK:

TypeScript

```
declare const codemode: {  search(query: string): Promise<SearchOutput>;  describe(target: string): Promise<DescribeOutput>;  step<T>(name: string, fn: () => T | Promise<T>): Promise<T>;  run(name: string, input?: unknown): Promise<unknown>;};
type SearchOutput = {  results: Array<{    path: string;    connector: string;    method: string;    description?: string;    kind: "method" | "snippet";    score: number;  }>;  total: number;  truncated: boolean;};
type DescribeOutput = {  path: string;  description?: string;  types: string;  kind: "connector" | "method" | "snippet";};
```

`codemode.search()` searches connector methods and saved snippets. It returns ranked paths, not complete schemas. The model can then pass one path to `codemode.describe()` to request focused TypeScript documentation.

`codemode.step()` records nondeterministic or side-effectful sandbox work for replay. `codemode.run()` invokes a saved snippet.

### Connector SDKs

Each configured connector becomes another sandbox global. A connector named `github` is available as `github`, and its methods appear under paths such as `github.list_pull_requests`.

A connector-level description returns declarations similar to:

TypeScript

```
type ListPullRequestsInput = {  owner: string;  repo: string;  state?: "open" | "closed";};
type ListPullRequestsOutput = unknown;
declare const github: {  list_pull_requests(    input: ListPullRequestsInput,  ): Promise<ListPullRequestsOutput>;};
```

These declarations are generated from connector schemas. They are illustrative; the actual method names, input fields, and output types depend on the connector.

The sandbox also includes standard JavaScript globals. It does not expose Node.js APIs, host credentials, `process`, `require`, or unrestricted network access. All external operations go through connector globals unless the executor explicitly provides another capability.

## Executor, connectors, and runtime

### Executor

An executor runs one block of model-generated code once. It receives callable namespaces and returns a result, an error, and captured console output. It does not retain execution history.

`DynamicWorkerExecutor` uses a [Dynamic Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) to create an isolated Worker for each execution pass. A resumed execution runs the code again in another pass. Durable state therefore cannot live inside the sandbox.

External `fetch()` and `connect()` calls are blocked by default. `DynamicWorkerExecutor` configures `globalOutbound: null` unless you provide another value. You can provide a `Fetcher` to route outbound requests through a controlled service.

### Connectors

Connectors bridge host-side services into the sandbox. A connector can wrap a Model Context Protocol (MCP) server, an OpenAPI document, an AI SDK toolset, or custom code.

Each connector becomes a global namespace. For example, a connector named `github` exposes calls such as `github.list_pull_requests()`. The generated code never receives the connector credentials or client objects.

Connector calls cross the sandbox boundary through [Workers remote procedure calls (RPC)](https://developers.cloudflare.com/workers/runtime-apis/rpc/). The runtime intercepts each call before the connector executes it. This interception applies approval, logging, replay, and rollback policy.

The `codemode` global provides discovery and runtime operations. `codemode.search()` finds connector methods and saved snippets. `codemode.describe()` returns focused TypeScript documentation without placing every connector schema in the model context.

### Durable runtime

The runtime connects the executor and connectors. It stores execution records, connector-call logs, pending approvals, and snippets in isolated SQLite storage. This state survives request completion and Durable Object hibernation.

The executor and connector instances remain transient. Your application provides them again when it handles a later approval or request.

A typical Agent creates all three parts together:

* [  JavaScript ](#tab-panel-6781)
* [  TypeScript ](#tab-panel-6782)

src/server.js

```
import {  createCodemodeRuntime,  DynamicWorkerExecutor,} from "@cloudflare/codemode";
const runtime = createCodemodeRuntime({  ctx: this.ctx,  executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),  connectors: [github, repoApi],});
const tools = { codemode: runtime.tool() };
```

src/server.ts

```
import {  createCodemodeRuntime,  DynamicWorkerExecutor,} from "@cloudflare/codemode";
const runtime = createCodemodeRuntime({  ctx: this.ctx,  executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),  connectors: [github, repoApi],});
const tools = { codemode: runtime.tool() };
```

Code Mode stores this state in a Durable Object facet. A facet is a durable child of the Agent with its own SQLite storage. `createCodemodeRuntime()` and the Vite plugin manage this implementation detail. You do not create or address the facet directly.

## Use multiple runtimes

Most Agents need only one Code Mode runtime. If you omit `name`, the runtime uses the name `default`.

Set `name` when one Agent needs separate Code Mode histories. For example, a runtime named `research` and another named `operations` keep separate execution records and snippet collections:

* [  JavaScript ](#tab-panel-6783)
* [  TypeScript ](#tab-panel-6784)

src/server.js

```
const researchRuntime = createCodemodeRuntime({  ctx: this.ctx,  executor,  connectors: researchConnectors,  name: "research",});
const operationsRuntime = createCodemodeRuntime({  ctx: this.ctx,  executor,  connectors: operationsConnectors,  name: "operations",});
```

src/server.ts

```
const researchRuntime = createCodemodeRuntime({  ctx: this.ctx,  executor,  connectors: researchConnectors,  name: "research",});
const operationsRuntime = createCodemodeRuntime({  ctx: this.ctx,  executor,  connectors: operationsConnectors,  name: "operations",});
```

A runtime name identifies its durable storage. It does not name the model, connector, tool, or individual execution.

Changing the connector set does not create another runtime. Each execution records every connector configured when it starts, and a saved snippet inherits that list. Approval replay and snippet execution require all recorded connectors to remain available, even if the original code did not call each one.

## Durable execution log

The runtime assigns each execution a stable ID. Each connector call and `codemode.step()` entry receives a sequence number. The log records its arguments, state, replay policy, and result when applicable.

The runtime marks a call as `executing` before invoking its connector. It marks the call as `applied` after recording the result. If the host stops before completing the pass, the execution can remain in `running`. A later `expirePaused()` maintenance call marks that stale execution as an error and releases its resources. Approval does not resume a stale `running` execution.

This log is the replay spine. It also supports developer audit views and determines which actions can be rolled back. It is not general conversation memory and does not replace Agent state.

## Approvals through abort and replay

A connector method can require user approval. When generated code reaches that method, the runtime records the action as pending and aborts the current pass. The action does not receive a provisional result.

The application can show the pending method and arguments to a user. Approval starts another pass with the same source code and execution ID. Calls already marked as applied return their recorded results instead of executing again. The approved action then executes, and the code continues until completion or another approval.

```
first pass:   read ── execute ──> result              write ── pause
approval
second pass:  read ── replay ───> recorded result              write ── execute ─> result              next call ────────> continue
```

This design lets an approval wait beyond a request or hibernation. Generated code remains linear and does not implement pause or resume logic.

Only a paused execution can resume. A stale approval cannot revive a completed, rejected, or rolled-back execution. Rejecting an action ends the execution, but it does not undo earlier actions. Rollback is a separate operation.

Execution failures are returned as data to the agent loop. Sandbox errors and replay divergence therefore do not need to escape as uncaught RPC exceptions.

## Deterministic replay

Replay requires connector calls and steps to occur in the same order. On every pass, a given sequence number must use the same connector, method, and arguments. A mismatch ends the execution with a replay-divergence error.

Recorded connector results make normal data-dependent branches stable. However, values from `Date.now()`, `Math.random()`, or other nondeterministic sources can change control flow or action arguments.

Use `codemode.step()` to capture such work once. The runtime records the closure result and returns that value during approval replay:

JavaScript

```
async () => {  const createdAt = await codemode.step("created-at", () => Date.now());
  return github.create_issue({    owner: "cloudflare",    repo: "agents",    title: `Review created at ${createdAt}`,  });};
```

Connector calls already pass through the runtime and do not need a step wrapper. Use steps for nondeterministic or side-effectful work outside connector calls. If you explicitly allow direct network access, this includes direct network operations that must not repeat during approval replay.

Issue connector calls sequentially when an execution might pause. The host assigns sequence numbers when calls arrive. Calls in `Promise.all()` can arrive in different orders across passes and cause replay divergence.

## Resource lifetimes

Some connectors need resources beyond one method call. Examples include browser sessions, database transactions, and temporary workspaces. Connector methods receive the stable execution ID, which can key durable resource metadata across passes.

Code Mode distinguishes two resource lifetimes:

* **Pass resources** last for one sandbox pass. The runtime invokes `onPassEnd()` after completed, failed, and paused passes.
* **Execution resources** last for the whole execution. The runtime invokes `disposeExecution()` after completion, failure, rejection, or rollback, but not after a pause.

A paused execution can resume in another Worker invocation. Connector lifecycle hooks must not depend on instance memory. Cleanup must also be idempotent because a completed execution can later be rolled back and disposed again.

The runtime calls lifecycle hooks for every configured connector. A connector that did not allocate a resource should safely do nothing. Cleanup errors are ignored so they do not turn a finished execution into a failed one.

## Replay policy

By default, the runtime stores a connector result and replays it on later passes. This preserves the exact value that the original code observed.

A connector can mark a call with `replay: "reexecute"`. The runtime still logs its sequence and arguments, but it does not store the result. A later pass runs the connector method again.

Use this policy only for idempotent reads with large, inexpensive results. The result can change between passes, so generated code must tolerate that change. Approval-required methods cannot use `replay: "reexecute"` because replay could apply an approved side effect more than once.

## Rollback

Rollback walks applied connector calls in reverse order. It invokes the `revert` implementation for every applied method that provides one, regardless of whether that method required approval.

For each applied connector call, the runtime asks the currently configured connector to run its `revert` implementation. Methods without `revert` remain applied. Missing connectors are also skipped. A failed revert does not stop later compensation attempts, and the runtime reports failures after trying the remaining calls. The execution moves to `rolled_back` only if at least one call is reverted.

Rollback is compensation, not database transaction isolation. Connector authors define what reversal means for each action. An external system may also change between the original call and its compensation.

## Retention and stale executions

The execution log is an audit trail and grows over time. When a new run begins, the runtime first inserts that run and then prunes older terminal executions. `maxExecutions` defaults to 50\. Because a running execution is not terminal, completion can temporarily leave 51 terminal records until another run begins or you call `pruneExecutions()`.

Running and paused executions are not pruned automatically. They may still need to finish or resume. Use `expirePaused()` from recurring maintenance to reclaim stale nonterminal runs. The runtime marks stale paused runs as rejected and stale running runs as errors, then disposes their execution resources.

You can also remove individual execution records or prune terminal history explicitly. Deleting a nonterminal execution disposes its execution-scoped resources.

## Durable value and result limits

Each value stored for durable replay has a serialized character limit of 1,000,000\. The implementation checks the JavaScript string length after serialization. This limit applies to connector arguments, recorded connector results, step results, and execution source code.

The runtime cannot truncate these values. Truncation would provide different data during replay. An oversized or unserializable replay value therefore fails the execution and suggests storing the data elsewhere, then passing a small reference such as a file path.

A final result has different behavior because replay does not consume it. The execution can complete and return the real result to the model. If the result cannot fit in the audit record, the runtime stores an omission message there instead.

`transformResult` can reshape the completed result before the model receives it. The transform runs after the runtime attempts to record the raw result. The audit trail retains the original value when it fits, while the model can receive a smaller representation.

## Snippets

A snippet is saved source from an execution. Snippets turn model-written programs into reusable recipes. They remain available across requests and hibernation.

The model does not promote its own code. Your application reviews an execution and calls `runtime.saveSnippet()` with its execution ID. The API accepts any execution status, so verify that the execution completed successfully before saving it. The model can then find the snippet with `codemode.search()`, inspect it with `codemode.describe()`, and invoke it with `codemode.run()`.

* [  JavaScript ](#tab-panel-6779)
* [  TypeScript ](#tab-panel-6780)

src/server.js

```
const runs = await runtime.executions(20);
await runtime.saveSnippet("list-open-prs", {  executionId: runs[0].id,  description: "List open pull requests for a repository.",});
```

src/server.ts

```
const runs = await runtime.executions(20);
await runtime.saveSnippet("list-open-prs", {  executionId: runs[0].id,  description: "List open pull requests for a repository.",});
```

A snippet can accept an input value. Its connector calls join the current execution log when the model runs it. The snippet also retains the connector list from its source execution. If a recorded connector is unavailable, `codemode.run()` resolves to an object with an `error` property. It does not throw automatically.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/codemode/how-it-works/#page","headline":"How Code Mode works · Cloudflare Agents docs","description":"Learn how Code Mode isolates generated code and makes approvals, replay, rollback, and reuse durable.","url":"https://developers.cloudflare.com/agents/tools/codemode/how-it-works/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/codemode/","name":"Code Mode"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/codemode/how-it-works/","name":"How Code Mode works"}}]}
```

---

---
title: Use MCP tools with Code Mode
description: Expose tools from an existing MCP connection to models through a durable Code Mode runtime.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Use MCP tools with Code Mode

Use `McpConnector` to expose tools from an existing Model Context Protocol (MCP) client connection inside the Code Mode sandbox. The connector works with the durable runtime, including discovery, approvals, and execution history.

This page covers an Agent consuming an MCP server. To publish Code Mode as an MCP server, refer to [Code Mode MCP server patterns](https://developers.cloudflare.com/agents/model-context-protocol/codemode/).

## Prerequisites

You need:

* A project with the [durable Code Mode runtime](https://developers.cloudflare.com/agents/tools/codemode/durable-runtime/) configured. That setup provides the Worker Loader binding and the `CodemodeRuntime` export.
* An existing Agents SDK MCP connection. To create and authorize the connection, refer to the [McpClient API](https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/).
1. **Install Code Mode**  
If your project does not already include Code Mode, install `@cloudflare/codemode`:  
 npm  yarn  pnpm  bun  
```  
npm i @cloudflare/codemode  
```  
```  
yarn add @cloudflare/codemode  
```  
```  
pnpm add @cloudflare/codemode  
```  
```  
bun add @cloudflare/codemode  
```
2. **Create an MCP connector**  
Create the connector in its own file. It is a plain class with no special file name or import syntax.

  * [  JavaScript ](#tab-panel-6789)
  * [  TypeScript ](#tab-panel-6790)  
src/github-connector.js  
```  
import { McpConnector } from "@cloudflare/codemode";  
export class GithubConnector extends McpConnector {  connection;  
  constructor(ctx, env, connection) {    super(ctx, env);    this.connection = connection;  }  
  name() {    return "github";  }  
  instructions() {    return "Use for GitHub repositories, issues, and pull requests.";  }  
  createConnection() {    return this.connection;  }  
  tool(name, tool) {    if (name === "create_issue") {      return { ...tool, requiresApproval: true };    }  
    return tool;  }}  
```  
src/github-connector.ts  
```  
import {  McpConnector,  type ConnectorTool,  type McpConnectionLike,} from "@cloudflare/codemode";  
export class GithubConnector extends McpConnector<Env> {  private connection: McpConnectionLike;  
  constructor(    ctx: DurableObjectState | ExecutionContext,    env: Env,    connection: McpConnectionLike,  ) {    super(ctx, env);    this.connection = connection;  }  
  override name() {    return "github";  }  
  protected override instructions() {    return "Use for GitHub repositories, issues, and pull requests.";  }  
  protected override createConnection() {    return this.connection;  }  
  protected override tool(    name: string,    tool: ConnectorTool,  ): ConnectorTool {    if (name === "create_issue") {      return { ...tool, requiresApproval: true };    }  
    return tool;  }}  
```  
`createConnection()` returns the existing Agents SDK connection. `name()` defines the sandbox global, so this connector exposes methods under `github`. Use a unique connector name within each runtime.  
`McpConnector` creates one typed sandbox method for each discovered MCP tool. It derives the method types from the MCP schemas. Each method calls the original tool through `connection.client.callTool()`.  
The connector sanitizes MCP tool names into valid JavaScript identifiers. For example, `list-pull.requests` becomes `list_pull_requests`, `3d-render` becomes `_3d_render`, and `delete` becomes `delete_`. If two source names produce the same identifier, the connector throws an error. Override `toolName()` to disambiguate those tools.  
The `tool()` decoration hook receives each generated method by its sanitized name. In this example, the hook marks `create_issue` for approval. The durable runtime pauses before executing that method and resumes the run after approval.
3. **Add the connector to the runtime**  
In your Agent, find the existing MCP connection and pass it to the connector. Then include the connector when you create the Code Mode runtime:

  * [  JavaScript ](#tab-panel-6787)
  * [  TypeScript ](#tab-panel-6788)  
src/server.js  
```  
import { Agent } from "agents";import {  createCodemodeRuntime,  DynamicWorkerExecutor,} from "@cloudflare/codemode";import { GithubConnector } from "./github-connector";  
export class Chat extends Agent {  async codemodeRuntime() {    await this.mcp.waitForConnections();  
    const server = this.mcp      .listServers()      .find((server) => server.name === "github");  
    if (!server) {      throw new Error("GitHub MCP server is not registered.");    }  
    const connection = this.mcp.mcpConnections[server.id];    if (!connection) {      throw new Error("GitHub MCP connection is not available.");    }  
    return createCodemodeRuntime({      ctx: this.ctx,      executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),      connectors: [new GithubConnector(this.ctx, this.env, connection)],    });  }}  
```  
src/server.ts  
```  
import { Agent } from "agents";import {  createCodemodeRuntime,  DynamicWorkerExecutor,} from "@cloudflare/codemode";import { GithubConnector } from "./github-connector";  
export class Chat extends Agent<Env> {  private async codemodeRuntime() {    await this.mcp.waitForConnections();  
    const server = this.mcp      .listServers()      .find((server) => server.name === "github");  
    if (!server) {      throw new Error("GitHub MCP server is not registered.");    }  
    const connection = this.mcp.mcpConnections[server.id];    if (!connection) {      throw new Error("GitHub MCP connection is not available.");    }  
    return createCodemodeRuntime({      ctx: this.ctx,      executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),      connectors: [        new GithubConnector(this.ctx, this.env, connection),      ],    });  }}  
```  
Await `codemodeRuntime()`, then pass `runtime.tool()` to your model as the `codemode` tool. Await the helper again before calling approval, rejection, rollback, or snippet methods. This ensures MCP connections have finished restoring after hibernation.
4. **Let the model discover and call tools**  
Tell the model to use `codemode.search()` and `codemode.describe()` before calling unfamiliar methods. Model-generated sandbox code can then discover and call the generated methods:  
JavaScript  
```  
async () => {  const matches = await codemode.search("open pull requests");  const docs = await codemode.describe(matches.results[0].path);  
  const pullRequests = await github.list_pull_requests({    owner: "cloudflare",    repo: "agents",    state: "open",  });  
  return { docs, pullRequests };};  
```  
`codemode.search()` returns ranked connector methods. `codemode.describe()` returns TypeScript documentation for a connector or method. This lets the model load tool details only when needed.

When the model calls `github.create_issue()`, the runtime returns a paused execution. Approve that execution through the runtime to execute the MCP tool and continue the same sandbox program.

## Use an AI SDK tool collection

For a smaller integration without durable approvals or `codemode.search()` and `codemode.describe()`, pass the Agents SDK tool collection directly to `createCodeTool()`:

* [  JavaScript ](#tab-panel-6785)
* [  TypeScript ](#tab-panel-6786)

JavaScript

```
import { DynamicWorkerExecutor } from "@cloudflare/codemode";import { createCodeTool } from "@cloudflare/codemode/ai";
await this.mcp.waitForConnections();
const executor = new DynamicWorkerExecutor({ loader: this.env.LOADER });const codemode = createCodeTool({  tools: this.mcp.getAITools(),  executor,});
```

TypeScript

```
import { DynamicWorkerExecutor } from "@cloudflare/codemode";import { createCodeTool } from "@cloudflare/codemode/ai";
await this.mcp.waitForConnections();
const executor = new DynamicWorkerExecutor({ loader: this.env.LOADER });const codemode = createCodeTool({  tools: this.mcp.getAITools(),  executor,});
```

This approach exposes the MCP tools under the default `codemode` namespace. It does not use the connector runtime's durable pause, approval, and resume flow. Use `McpConnector` when tools can cause side effects or when the model needs on-demand discovery.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/codemode/mcp/#page","headline":"Use MCP tools with Code Mode · Cloudflare Agents docs","description":"Expose tools from an existing MCP connection to models through a durable Code Mode runtime.","url":"https://developers.cloudflare.com/agents/tools/codemode/mcp/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/codemode/","name":"Code Mode"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/codemode/mcp/","name":"Use MCP tools with Code Mode"}}]}
```

---

---
title: Use an OpenAPI service with Code Mode
description: Turn OpenAPI operations into typed Code Mode connector methods while keeping authentication in the host Worker.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Use an OpenAPI service with Code Mode

Use `OpenApiConnector` to expose an OpenAPI service inside a durable Code Mode runtime. The connector derives one sandbox method for each operation in the OpenAPI document.

The model can discover methods with `codemode.search()` and request focused input types with `codemode.describe()`. The complete OpenAPI document does not need to enter the model context.

This page covers an Agent consuming an OpenAPI service. To publish an OpenAPI service to external MCP clients through `search` and `execute`, refer to [Build a search and execute MCP server](https://developers.cloudflare.com/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server/).

## Prerequisites

You need a project with the [durable Code Mode runtime](https://developers.cloudflare.com/agents/tools/codemode/durable-runtime/) configured. The runtime setup provides the Worker Loader binding and the `CodemodeRuntime` export used in this guide.

## Create an OpenAPI connector

1. Add the OpenAPI document to your project. Give each operation a unique `operationId` so it produces a stable sandbox method name:

  * [  JavaScript ](#tab-panel-6793)
  * [  TypeScript ](#tab-panel-6794)  
src/orders-openapi.js  
```  
export const ordersOpenApiSpec = {  openapi: "3.1.0",  info: { title: "Orders API", version: "1.0.0" },  paths: {    "/orders/{orderId}": {      get: {        operationId: "get_order",        summary: "Get an order by ID.",        parameters: [          {            name: "orderId",            in: "path",            required: true,            schema: { type: "string" },          },        ],      },    },    "/orders": {      post: {        operationId: "create_order",        summary: "Create an order.",        requestBody: {          required: true,          content: {            "application/json": {              schema: {                type: "object",                properties: {                  productId: { type: "string" },                  quantity: { type: "integer" },                },                required: ["productId", "quantity"],              },            },          },        },      },    },  },};  
```  
src/orders-openapi.ts  
```  
export const ordersOpenApiSpec = {  openapi: "3.1.0",  info: { title: "Orders API", version: "1.0.0" },  paths: {    "/orders/{orderId}": {      get: {        operationId: "get_order",        summary: "Get an order by ID.",        parameters: [          {            name: "orderId",            in: "path",            required: true,            schema: { type: "string" },          },        ],      },    },    "/orders": {      post: {        operationId: "create_order",        summary: "Create an order.",        requestBody: {          required: true,          content: {            "application/json": {              schema: {                type: "object",                properties: {                  productId: { type: "string" },                  quantity: { type: "integer" },                },                required: ["productId", "quantity"],              },            },          },        },      },    },  },} as const;  
```
2. Create the connector. Implement `spec()` to return the document and `request()` to make authenticated host-side requests:

  * [  JavaScript ](#tab-panel-6795)
  * [  TypeScript ](#tab-panel-6796)  
src/orders-connector.js  
```  
import { OpenApiConnector } from "@cloudflare/codemode";import { ordersOpenApiSpec } from "./orders-openapi";  
const API_ORIGIN = "https://api.example.com";  
export class OrdersConnector extends OpenApiConnector {  name() {    return "orders";  }  
  instructions() {    return "Use for reading and creating orders.";  }  
  spec() {    return ordersOpenApiSpec;  }  
  async request(options) {    if (!options.path.startsWith("/")) {      throw new Error("Orders API path must start with a slash");    }  
    const url = new URL(options.path, API_ORIGIN);    for (const [key, value] of Object.entries(options.params ?? {})) {      if (value !== undefined) {        url.searchParams.set(key, String(value));      }    }  
    const response = await fetch(url, {      method: options.method ?? "GET",      headers: {        ...(options.body !== undefined          ? { "Content-Type": "application/json" }          : {}),        ...options.headers,        Authorization: `Bearer ${this.env.ORDERS_API_TOKEN}`,      },      body:        options.body === undefined ? undefined : JSON.stringify(options.body),    });  
    if (!response.ok) {      throw new Error(`Orders API request failed: ${response.status}`);    }    if (response.status === 204) return null;    return response.json();  }  
  tool(name, tool) {    if (name === "create_order") {      return { ...tool, requiresApproval: true };    }    return tool;  }}  
```  
src/orders-connector.ts  
```  
import {  OpenApiConnector,  type ConnectorTool,  type OpenApiRequestOptions,} from "@cloudflare/codemode";import { ordersOpenApiSpec } from "./orders-openapi";  
const API_ORIGIN = "https://api.example.com";  
export class OrdersConnector extends OpenApiConnector<Env> {  override name() {    return "orders";  }  
  protected override instructions() {    return "Use for reading and creating orders.";  }  
  protected override spec() {    return ordersOpenApiSpec;  }  
  protected override async request(options: OpenApiRequestOptions) {    if (!options.path.startsWith("/")) {      throw new Error("Orders API path must start with a slash");    }  
    const url = new URL(options.path, API_ORIGIN);    for (const [key, value] of Object.entries(options.params ?? {})) {      if (value !== undefined) {        url.searchParams.set(key, String(value));      }    }  
    const response = await fetch(url, {      method: options.method ?? "GET",      headers: {        ...(options.body !== undefined          ? { "Content-Type": "application/json" }          : {}),        ...options.headers,        Authorization: `Bearer ${this.env.ORDERS_API_TOKEN}`,      },      body:        options.body === undefined          ? undefined          : JSON.stringify(options.body),    });  
    if (!response.ok) {      throw new Error(`Orders API request failed: ${response.status}`);    }    if (response.status === 204) return null;    return response.json();  }  
  protected override tool(name: string, tool: ConnectorTool): ConnectorTool {    if (name === "create_order") {      return { ...tool, requiresApproval: true };    }    return tool;  }}  
```  
Credentials remain in the host Worker. Model-written code receives connector methods and their results, not `ORDERS_API_TOKEN`.  
The `tool()` hook decorates derived operations. This example requires approval before `create_order` executes. You can also use the hook to add replay or rollback behavior.
3. Import the connector and add it to the runtime:

  * [  JavaScript ](#tab-panel-6791)
  * [  TypeScript ](#tab-panel-6792)  
src/server.js  
```  
import { AIChatAgent } from "@cloudflare/ai-chat";import {  createCodemodeRuntime,  DynamicWorkerExecutor,} from "@cloudflare/codemode";import { OrdersConnector } from "./orders-connector";  
export class Chat extends AIChatAgent {  #runtime() {    return createCodemodeRuntime({      ctx: this.ctx,      executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),      connectors: [new OrdersConnector(this.ctx, this.env)],    });  }  
  async onChatMessage() {    const tools = { codemode: this.#runtime().tool() };    // Pass tools to your model call.  }}  
```  
src/server.ts  
```  
import { AIChatAgent } from "@cloudflare/ai-chat";import {  createCodemodeRuntime,  DynamicWorkerExecutor,} from "@cloudflare/codemode";import { OrdersConnector } from "./orders-connector";  
export class Chat extends AIChatAgent<Env> {  #runtime() {    return createCodemodeRuntime({      ctx: this.ctx,      executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),      connectors: [new OrdersConnector(this.ctx, this.env)],    });  }  
  async onChatMessage() {    const tools = { codemode: this.#runtime().tool() };    // Pass tools to your model call.  }}  
```
4. Let the model discover the operations and call the generated connector methods:  
JavaScript  
```  
async () => {  const matches = await codemode.search("get an order by ID");  const docs = await codemode.describe(matches.results[0].path);  
  const order = await orders.get_order({ orderId: "order-123" });  return { docs, order };};  
```

## Derived method behavior

`OpenApiConnector` uses a sanitized `operationId` as the method name. If an operation has no `operationId`, it derives a name from the HTTP method and path. Define unique operation IDs to keep method names stable and avoid collisions.

Each generated method accepts one object:

* Path, query, and header parameters appear as top-level fields.
* A JSON request body appears under `body`.
* Required OpenAPI parameters become required TypeScript fields.
* Local `$ref` values in input schemas are resolved before types are generated.

The connector substitutes path parameters and passes a normalized `{ path, method, params, body, headers }` object to `request()`.

The current connector derives input types but does not derive response types from OpenAPI response schemas. Generated methods therefore return `unknown` unless your application provides more specific declarations through another connector implementation.

## Request escape hatch

Every OpenAPI connector also exposes a low-level `request()` sandbox method. Use it when the OpenAPI document does not describe an operation the model needs:

JavaScript

```
const result = await orders.request({  path: "/orders",  method: "GET",  params: { status: "processing" },});
```

Prefer derived operation methods when available. They provide discoverable descriptions and generated input types.

`exposeSpec()` returns `false` by default. Override it to return `true` only when model-written code needs access to the raw OpenAPI document. Large documents can produce large results and durable log entries.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/codemode/openapi/#page","headline":"Use an OpenAPI service with Code Mode · Cloudflare Agents docs","description":"Turn OpenAPI operations into typed Code Mode connector methods while keeping authentication in the host Worker.","url":"https://developers.cloudflare.com/agents/tools/codemode/openapi/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/codemode/","name":"Code Mode"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/codemode/openapi/","name":"Use an OpenAPI service with Code Mode"}}]}
```

---

---
title: Use Code Mode with TanStack AI
description: Use @cloudflare/codemode/tanstack-ai to expose namespaced TanStack AI server tools through chat().
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Use Code Mode with TanStack AI

Use the `@cloudflare/codemode/tanstack-ai` entry point to give `chat()` one Code Mode tool. The model can then write JavaScript that calls your TanStack AI server tools.

## Prerequisites

You need an existing Workers project and a configured TanStack AI model adapter. This example uses the OpenAI adapter.

## Add Code Mode

1. Install Code Mode, TanStack AI, the OpenAI adapter, and Zod:  
 npm  yarn  pnpm  bun  
```  
npm i @cloudflare/codemode @tanstack/ai @tanstack/ai-openai zod  
```  
```  
yarn add @cloudflare/codemode @tanstack/ai @tanstack/ai-openai zod  
```  
```  
pnpm add @cloudflare/codemode @tanstack/ai @tanstack/ai-openai zod  
```  
```  
bun add @cloudflare/codemode @tanstack/ai @tanstack/ai-openai zod  
```
2. Add a Worker Loader binding to your Wrangler configuration:

  * [  wrangler.jsonc ](#tab-panel-6797)
  * [  wrangler.toml ](#tab-panel-6798)  
JSONC  
```  
{  "$schema": "./node_modules/wrangler/config-schema.json",  "name": "tanstack-codemode",  "main": "src/index.ts",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": [    "nodejs_compat"  ],  "worker_loaders": [    {      "binding": "LOADER"    }  ]}  
```  
TOML  
```  
name = "tanstack-codemode"main = "src/index.ts"# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = ["nodejs_compat"]  
[[worker_loaders]]binding = "LOADER"  
```
3. Define TanStack AI server tools, group them into namespaces, and pass the Code Mode tool to `chat()`:

  * [  JavaScript ](#tab-panel-6801)
  * [  TypeScript ](#tab-panel-6802)  
src/index.js  
```  
import { DynamicWorkerExecutor } from "@cloudflare/codemode";import {  createCodeTool,  tanstackTools,} from "@cloudflare/codemode/tanstack-ai";import { chat, toolDefinition, toHttpResponse } from "@tanstack/ai";import { openaiText } from "@tanstack/ai-openai";import { z } from "zod";  
const getWeather = toolDefinition({  name: "get_weather",  description: "Get the current weather for a city",  inputSchema: z.object({    city: z.string().meta({ description: "City name" }),  }),  outputSchema: z.object({    city: z.string(),    temperatureCelsius: z.number(),    conditions: z.string(),  }),}).server(async ({ city }) => ({  city,  temperatureCelsius: 22,  conditions: "sunny",}));  
const findContacts = toolDefinition({  name: "find_contacts",  description: "Find contacts for a team",  inputSchema: z.object({    team: z.string().meta({ description: "Team name" }),  }),  outputSchema: z.array(    z.object({      name: z.string(),      email: z.string(),    }),  ),}).server(async ({ team }) => [  {    name: `${team} contact`,    email: "team@example.com",  },]);  
function startChat(env, prompt) {  const executor = new DynamicWorkerExecutor({ loader: env.LOADER });  
  const codeTool = createCodeTool({    tools: [      tanstackTools([getWeather], "weather"),      tanstackTools([findContacts], "directory"),    ],    executor,  });  
  return chat({    adapter: openaiText("gpt-4o"),    messages: [{ role: "user", content: prompt }],    tools: [codeTool],  });}  
export default {  async fetch(request, env) {    const prompt = await request.text();    return toHttpResponse(startChat(env, prompt));  },};  
```  
src/index.ts  
```  
import { DynamicWorkerExecutor } from "@cloudflare/codemode";import {  createCodeTool,  tanstackTools,} from "@cloudflare/codemode/tanstack-ai";import { chat, toolDefinition, toHttpResponse } from "@tanstack/ai";import { openaiText } from "@tanstack/ai-openai";import { z } from "zod";  
const getWeather = toolDefinition({  name: "get_weather",  description: "Get the current weather for a city",  inputSchema: z.object({    city: z.string().meta({ description: "City name" }),  }),  outputSchema: z.object({    city: z.string(),    temperatureCelsius: z.number(),    conditions: z.string(),  }),}).server(async ({ city }) => ({  city,  temperatureCelsius: 22,  conditions: "sunny",}));  
const findContacts = toolDefinition({  name: "find_contacts",  description: "Find contacts for a team",  inputSchema: z.object({    team: z.string().meta({ description: "Team name" }),  }),  outputSchema: z.array(    z.object({      name: z.string(),      email: z.string(),    }),  ),}).server(async ({ team }) => [  {    name: `${team} contact`,    email: "team@example.com",  },]);  
function startChat(env: Env, prompt: string) {  const executor = new DynamicWorkerExecutor({ loader: env.LOADER });  
  const codeTool = createCodeTool({    tools: [      tanstackTools([getWeather], "weather"),      tanstackTools([findContacts], "directory"),    ],    executor,  });  
  return chat({    adapter: openaiText("gpt-4o"),    messages: [{ role: "user", content: prompt }],    tools: [codeTool],  });}  
export default {  async fetch(request, env): Promise<Response> {    const prompt = await request.text();    return toHttpResponse(startChat(env, prompt));  },} satisfies ExportedHandler<Env>;  
```

`createCodeTool()` returns a TanStack AI `ServerTool` named `codemode_execute`. Its description contains the generated types for both namespaces. The model can write code similar to this:

JavaScript

```
async () => {  const weatherResult = await weather.get_weather({ city: "London" });  const contacts = await directory.find_contacts({ team: "travel" });  return { weatherResult, contacts };};
```

## Namespace behavior

`tanstackTools(tools, name)` converts an array of TanStack AI tools into a Code Mode tool provider. It uses each tool name as the method name and generates types from its input and output schemas.

The optional second argument sets the sandbox namespace. For example, `tanstackTools([getWeather], "weather")` exposes `weather.get_weather()`. If you omit the name, Code Mode uses the default `codemode` namespace:

* [  JavaScript ](#tab-panel-6799)
* [  TypeScript ](#tab-panel-6800)

JavaScript

```
const codeTool = createCodeTool({  tools: [tanstackTools([getWeather])],  executor,});
// Available to model-generated code as codemode.get_weather().
```

TypeScript

```
const codeTool = createCodeTool({  tools: [tanstackTools([getWeather])],  executor,});
// Available to model-generated code as codemode.get_weather().
```

Use distinct namespace names when you combine tool groups. Each provider contributes its generated declarations and executable server tools to the same Code Mode tool.

## Approval behavior

The `createCodeTool()` integration does not pause execution for TanStack AI approvals. `tanstackTools()` excludes a tool when its `needsApproval` property is `true` or a function. The excluded tool does not appear in generated type declarations and cannot run in the sandbox.

Tools with `needsApproval: false` remain available. The durable Code Mode runtime supports paused approvals through connector `requiresApproval` annotations, but this `createCodeTool()` integration does not use that approval flow.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/codemode/tanstack-ai/#page","headline":"Use Code Mode with TanStack AI · Cloudflare Agents docs","description":"Use @cloudflare/codemode/tanstack-ai to expose namespaced TanStack AI server tools through chat().","url":"https://developers.cloudflare.com/agents/tools/codemode/tanstack-ai/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/codemode/","name":"Code Mode"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/codemode/tanstack-ai/","name":"Use Code Mode with TanStack AI"}}]}
```

---

---
title: MCP
description: Connect agents to external Model Context Protocol servers and use their tools in model calls.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# MCP

Agents can use [Model Context Protocol (MCP)](https://developers.cloudflare.com/agents/model-context-protocol/) as clients. Connect an agent to external MCP servers, discover the tools those servers expose, and pass those tools into model calls.

Use MCP when you want an agent to:

* Call tools exposed by external MCP servers.
* Reuse tools across agents, IDEs, and other AI clients.
* Connect to services that already expose an MCP endpoint.
* Add OAuth or token-based authorization around external tool access.

To build an MCP server instead, refer to [Model Context Protocol (MCP)](https://developers.cloudflare.com/agents/model-context-protocol/).

## Basic pattern

Call `addMcpServer()` to connect to a remote MCP server, then pass `this.mcp.getAITools()` to the AI SDK.

* [  JavaScript ](#tab-panel-6805)
* [  TypeScript ](#tab-panel-6806)

JavaScript

```
import { Agent } from "agents";import { generateText } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class ToolAgent extends Agent {  async onStart() {    await this.addMcpServer("github", "https://mcp.github.com/mcp");  }
  async onRequest(request) {    const workersai = createWorkersAI({ binding: this.env.AI });
    const response = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: "Use available tools to summarize the latest issue activity.",      tools: this.mcp.getAITools(),    });
    return new Response(response.text);  }}
```

TypeScript

```
import { Agent } from "agents";import { generateText } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class ToolAgent extends Agent<Env> {  async onStart() {    await this.addMcpServer("github", "https://mcp.github.com/mcp");  }
  async onRequest(request: Request) {    const workersai = createWorkersAI({ binding: this.env.AI });
    const response = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: "Use available tools to summarize the latest issue activity.",      tools: this.mcp.getAITools(),    });
    return new Response(response.text);  }}
```

If the server requires OAuth, `addMcpServer()` returns an authentication state and authorization URL. The connection is persisted in the agent's [SQL storage](https://developers.cloudflare.com/agents/runtime/lifecycle/state/).

## Configuration

For public MCP servers, no binding configuration is required. Store server URLs, API tokens, or OAuth settings as environment variables or secrets.

For MCP servers that require bearer tokens or Cloudflare Access headers, pass custom transport headers when connecting.

* [  JavaScript ](#tab-panel-6803)
* [  TypeScript ](#tab-panel-6804)

JavaScript

```
await this.addMcpServer("internal", this.env.MCP_SERVER_URL, {  transport: {    headers: {      Authorization: `Bearer ${this.env.MCP_TOKEN}`,      "CF-Access-Client-Id": this.env.CF_ACCESS_CLIENT_ID,      "CF-Access-Client-Secret": this.env.CF_ACCESS_CLIENT_SECRET,    },  },});
```

TypeScript

```
await this.addMcpServer("internal", this.env.MCP_SERVER_URL, {  transport: {    headers: {      Authorization: `Bearer ${this.env.MCP_TOKEN}`,      "CF-Access-Client-Id": this.env.CF_ACCESS_CLIENT_ID,      "CF-Access-Client-Secret": this.env.CF_ACCESS_CLIENT_SECRET,    },  },});
```

## Related resources

[ McpClient API ](https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/) Connect Agents to external MCP servers and use their tools, resources, and prompts. 

[ Connect to an MCP server ](https://developers.cloudflare.com/agents/model-context-protocol/guides/connect-mcp-client/) Create an Agent that connects to an external MCP server and uses its tools. 

[ Use MCP tools with Code Mode ](https://developers.cloudflare.com/agents/tools/codemode/mcp/) Use progressive discovery, code-based composition, and durable approvals with MCP tools. 

[ Model Context Protocol specification ](https://modelcontextprotocol.io/) Learn about the open protocol for connecting AI applications to external tools and data.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/mcp/#page","headline":"MCP · Cloudflare Agents docs","description":"Connect agents to external Model Context Protocol servers and use their tools in model calls.","url":"https://developers.cloudflare.com/agents/tools/mcp/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/mcp/","name":"MCP"}}]}
```

---

---
title: Agentic Payments
description: Let AI agents pay for services programmatically using payment protocols like MPP and x402 with Cloudflare's Agents SDK.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Agentic Payments

AI agents need to discover, pay for, and consume resources and services programmatically. Traditional onboarding requires account creation, a payment method, and an API key before an agent can pay for a service. Agentic payments let AI agents purchase resources and services directly through the HTTP `402 Payment Required` response code.

Cloudflare's [Agents SDK](https://developers.cloudflare.com/agents/) supports agentic payments through two protocols built on the HTTP `402 Payment Required` status code: **x402** and **Machine Payments Protocol (MPP)**. Both follow the same core flow:

1. A client requests a resource or calls a tool.
2. The server responds with `402` and a payment challenge describing what to pay, how much, and where.
3. The client fulfills the payment and retries the request with a payment credential.
4. The server verifies the payment (optionally through a facilitator service) and returns the resource along with a receipt.

No accounts, sessions, or pre-shared API keys are required. Agents handle the entire exchange programmatically.

## x402 and Machine Payments Protocol

### x402

[x402 ↗](https://www.x402.org/) is a payment standard created by Coinbase. It uses on-chain stablecoin payments (USDC on Base, Ethereum, Solana, and other networks) and defines three HTTP headers — `PAYMENT-REQUIRED`, `PAYMENT-SIGNATURE`, and `PAYMENT-RESPONSE` — to carry challenges, credentials, and receipts. Servers can offload verification and settlement to a **facilitator** service so they do not need direct blockchain connectivity. It is governed by Coinbase and Cloudflare, two of the founding members of the x402 Foundation.

The Agents SDK provides first-class x402 integration:

* **Server-side**: `withX402` and `paidTool` for MCP servers, plus `x402-hono` middleware for HTTP Workers.
* **Client-side**: `withX402Client` wraps MCP client connections with automatic 402 handling and optional human-in-the-loop confirmation.

### Machine Payments Protocol

[Machine Payments Protocol (MPP) ↗](https://mpp.dev) is a protocol co-authored by Tempo Labs and Stripe. It extends the HTTP `402` pattern with a formal `WWW-Authenticate: Payment` / `Authorization: Payment` header scheme and is on the IETF standards track.

MPP supports multiple payment methods beyond blockchain — including cards (via Stripe), Bitcoin Lightning, and stablecoins — and introduces **sessions** for streaming and pay-as-you-go use cases with sub-millisecond latency and sub-cent costs. MPP is backwards-compatible with x402: MPP clients can consume existing x402 services without modification.

## Charge for resources

[ HTTP content (x402) ](https://developers.cloudflare.com/agents/tools/payments/x402/charge-for-http-content/) Gate APIs, web pages, and files with a Worker proxy 

[ HTTP content (MPP) ](https://developers.cloudflare.com/agents/tools/payments/mpp-charge-for-http-content/) Gate APIs, web pages, and files with a Worker proxy 

## Related

* [x402.org ↗](https://x402.org) — x402 protocol specification
* [mpp.dev ↗](https://mpp.dev) — MPP protocol specification
* [Pay Per Crawl](https://developers.cloudflare.com/ai-crawl-control/features/pay-per-crawl/) — Cloudflare-native monetization for web content
* [x402 examples ↗](https://github.com/cloudflare/agents/tree/main/examples) — Complete working code

```json
{"@context":"https://schema.org","@type":"WebPage","@id":"https://developers.cloudflare.com/agents/tools/payments/#page","headline":"Agentic Payments · Cloudflare Agents docs","description":"Let AI agents pay for services programmatically using payment protocols like MPP and x402 with Cloudflare's Agents SDK.","url":"https://developers.cloudflare.com/agents/tools/payments/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/payments/","name":"Agentic Payments"}}]}
```

---

---
title: MPP (Machine Payments Protocol)
description: Accept and make payments using the Machine Payments Protocol (MPP) on Cloudflare Workers.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# MPP (Machine Payments Protocol)

[Machine Payments Protocol (MPP) ↗](https://mpp.dev) is a protocol for machine-to-machine payments, co-authored by [Tempo Labs ↗](https://tempo.xyz) and [Stripe ↗](https://stripe.com). It standardizes the HTTP `402 Payment Required` status code with a formal authentication scheme proposed to the [IETF ↗](https://paymentauth.org). MPP gives agents, apps, and humans a single interface to pay for any service in the same HTTP request.

MPP is payment-method agnostic. A single endpoint can accept stablecoins (Tempo), credit cards (Stripe), or Bitcoin (Lightning).

## How it works

1. A client requests a resource — `GET /resource`.
2. The server returns `402 Payment Required` with a `WWW-Authenticate: Payment` header containing a payment challenge.
3. The client fulfills the payment — signs a transaction, pays an invoice, or completes a card payment.
4. The client retries the request with an `Authorization: Payment` header containing a payment credential.
5. The server verifies the payment and returns the resource with a `Payment-Receipt` header.

## Payment methods

MPP supports multiple payment methods through a single protocol:

| Method                                                   | Description                                                                  | Status     |
| -------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------- |
| [Tempo ↗](https://mpp.dev/payment-methods/tempo)         | Stablecoin payments on the Tempo blockchain with sub-second settlement       | Production |
| [Stripe ↗](https://mpp.dev/payment-methods/stripe)       | Cards, wallets, and other Stripe-supported methods via Shared Payment Tokens | Production |
| [Lightning ↗](https://mpp.dev/payment-methods/lightning) | Bitcoin payments over the Lightning Network                                  | Available  |
| [Card ↗](https://mpp.dev/payment-methods/card)           | Card payments via encrypted network tokens                                   | Available  |
| [Custom ↗](https://mpp.dev/payment-methods/custom)       | Build your own payment method using the MPP SDK                              | Available  |

Servers can offer multiple methods simultaneously. Clients choose the method that works for them.

## Payment intents

MPP defines two payment intents:

* **`charge`** — A one-time payment that settles immediately. Use for per-request billing.
* **`session`** — A streaming payment over a payment channel. Use for pay-as-you-go or per-token billing with sub-cent costs and sub-millisecond latency.

## Compatibility with x402

MPP is backwards-compatible with [x402](https://developers.cloudflare.com/agents/tools/payments/x402/). The core x402 `exact` payment flows map directly onto MPP's `charge` intent, so MPP clients can consume existing x402 services without modification.

## Charge for resources

[ HTTP content ](https://developers.cloudflare.com/agents/tools/payments/mpp-charge-for-http-content/) Gate APIs, web pages, and files with MPP middleware 

## SDKs

MPP provides official SDKs in three languages:

| SDK        | Package | Install           |
| ---------- | ------- | ----------------- |
| TypeScript | mppx    | npm install mppx  |
| Python     | pympp   | pip install pympp |
| Rust       | mpp-rs  | cargo add mpp     |

The TypeScript SDK includes framework middleware for [Hono ↗](https://mpp.dev/sdk/typescript/middlewares/hono), [Express ↗](https://mpp.dev/sdk/typescript/middlewares/express), [Next.js ↗](https://mpp.dev/sdk/typescript/middlewares/nextjs), and [Elysia ↗](https://mpp.dev/sdk/typescript/middlewares/elysia), as well as a [CLI ↗](https://mpp.dev/sdk/typescript/cli) for testing paid endpoints.

## Related

* [mpp.dev ↗](https://mpp.dev) — Protocol documentation and quickstart guides
* [IETF specification ↗](https://paymentauth.org) — Full Payment HTTP Authentication Scheme specification
* [Pay Per Crawl](https://developers.cloudflare.com/ai-crawl-control/features/pay-per-crawl/) — Cloudflare-native monetization for web content

```json
{"@context":"https://schema.org","@type":"WebPage","@id":"https://developers.cloudflare.com/agents/tools/payments/mpp/#page","headline":"MPP (Machine Payments Protocol) · Cloudflare Agents docs","description":"Accept and make payments using the Machine Payments Protocol (MPP) on Cloudflare Workers.","url":"https://developers.cloudflare.com/agents/tools/payments/mpp/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/payments/","name":"Agentic Payments"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/payments/mpp/","name":"MPP (Machine Payments Protocol)"}}]}
```

---

---
title: Charge for HTTP content
description: Gate HTTP endpoints with MPP payments using the mpp-proxy template on Cloudflare Workers.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Charge for HTTP content

The [mpp-proxy ↗](https://github.com/cloudflare/mpp-proxy) template is a Cloudflare Worker that sits in front of any HTTP backend. When a request hits a protected route, the proxy returns a `402` response with an MPP payment challenge. After the client pays, the proxy verifies the payment, forwards the request to your origin, and issues a 1-hour session cookie.

Deploy the mpp-proxy template to your Cloudflare account:

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/mpp-proxy)

## Prerequisites

* A [Cloudflare account ↗](https://dash.cloudflare.com/sign-up)
* An HTTP backend to gate
* A wallet address to receive payments

## Configuration

Define protected routes in `wrangler.jsonc`:

JSONC

```
{  "vars": {    "PAY_TO": "0xYourWalletAddress",    "TEMPO_TESTNET": false,    "PAYMENT_CURRENCY": "0x20c000000000000000000000b9537d11c60e8b50",    "PROTECTED_PATTERNS": [      {        "pattern": "/premium/*",        "amount": "0.01",        "description": "Access to premium content for 1 hour"      }    ]  }}
```

Note

Set `TEMPO_TESTNET` to `true` and `PAYMENT_CURRENCY` to `0x20c0000000000000000000000000000000000000` for testnet development.

## Selective gating with Bot Management

With [Bot Management](https://developers.cloudflare.com/bots/), the proxy can charge crawlers while keeping the site free for humans:

JSONC

```
{  "pattern": "/content/*",  "amount": "0.25",  "description": "Content access for 1 hour",  "bot_score_threshold": 30,  "except_detection_ids": [120623194, 117479730]}
```

Requests with a bot score at or below `bot_score_threshold` are directed to the paywall. Use `except_detection_ids` to allowlist specific crawlers by [detection ID](https://developers.cloudflare.com/ai-crawl-control/reference/bots/).

## Deploy

Clone the template, edit `wrangler.jsonc`, and deploy:

Terminal window

```
git clone https://github.com/cloudflare/mpp-proxycd mpp-proxynpm installnpx wrangler secret put JWT_SECRETnpx wrangler secret put MPP_SECRET_KEYnpx wrangler deploy
```

For full configuration options, proxy modes, and Bot Management examples, refer to the [mpp-proxy README ↗](https://github.com/cloudflare/mpp-proxy).

## Custom Worker endpoints

For more control, add MPP middleware directly to your Worker using Hono:

TypeScript

```
import { Hono } from "hono";import { Mppx, tempo } from "mppx/hono";
const app = new Hono();
const mppx = Mppx.create({  methods: [    tempo({      currency: "0x20c0000000000000000000000000000000000000",      recipient: "0xYourWalletAddress",    }),  ],});
app.get("/premium", mppx.charge({ amount: "0.10" }), (c) =>  c.json({ data: "Thanks for paying!" }),);
export default app;
```

Refer to the [Hono middleware reference ↗](https://mpp.dev/sdk/typescript/middlewares/hono) for the full API, including session payments and payer identification.

## Related

* [mpp.dev ↗](https://mpp.dev) — Protocol specification
* [Pay Per Crawl](https://developers.cloudflare.com/ai-crawl-control/features/pay-per-crawl/) — Cloudflare-native monetization without custom code

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/payments/mpp-charge-for-http-content/#page","headline":"Charge for HTTP content · Cloudflare Agents docs","description":"Gate HTTP endpoints with MPP payments using the mpp-proxy template on Cloudflare Workers.","url":"https://developers.cloudflare.com/agents/tools/payments/mpp-charge-for-http-content/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/payments/","name":"Agentic Payments"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/payments/mpp-charge-for-http-content/","name":"Charge for HTTP content"}}]}
```

---

---
title: x402
description: Accept and make machine-to-machine payments using the x402 HTTP payment protocol on Cloudflare Workers and the Agents SDK.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# x402

[x402 ↗](https://www.x402.org/) is a payment standard built around HTTP 402 (Payment Required). Services return a 402 response with payment instructions, and clients pay programmatically without accounts, sessions, or API keys.

## How it works

1. A client requests a resource — `GET /resource`.
2. The server returns `402 Payment Required` with a `PAYMENT-REQUIRED` header containing Base64-encoded payment details: the price, accepted token, network, and merchant address.
3. The client constructs a signed payment payload and retries the request with a `PAYMENT-SIGNATURE` header.
4. The server verifies the payment payload — directly or by calling a [facilitator](#the-facilitator) — and settles the transaction on-chain.
5. The server returns the resource with a `PAYMENT-RESPONSE` header containing settlement confirmation.

## Key components

### Client

The client is any entity that requests a paid resource: a human-operated app, an AI agent, or a programmatic service. Clients need only a crypto wallet — no accounts, credentials, or session tokens to manage.

### Server

The server defines payment requirements in the `402` response, verifies incoming payment payloads, settles the transaction, and serves the resource. The x402 SDKs and a facilitator handle most of this automatically.

### The facilitator

The facilitator is an optional but recommended third-party service that abstracts blockchain interaction. Rather than connecting to a node directly, the server delegates two operations:

* **`POST /verify`** — Confirms the client's payment payload is valid before the server fulfills the request.
* **`POST /settle`** — Submits the verified payment transaction to the blockchain.

The facilitator does not hold funds. It verifies and broadcasts the client's pre-signed transaction on behalf of the server. `https://x402.org/facilitator` is the public facilitator operated by Coinbase and is used in all Cloudflare examples. [Multiple facilitators ↗](https://www.x402.org/ecosystem?filter=facilitators) are available across different networks.

## Payment schemes and networks

x402 uses payment **schemes** to define how a payment is constructed and settled on a given network.

| Scheme                                                                                             | Networks                                 | Description                                                                                                                         |
| -------------------------------------------------------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| [exact ↗](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme%5Fexact.md) | EVM, Solana, Aptos, Stellar, Hedera, Sui | Transfers a fixed token amount — typically [ERC-20 ↗](https://eips.ethereum.org/EIPS/eip-20) USDC on EVM — to the merchant address. |
| [upto ↗](https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme%5Fupto.md)    | EVM                                      | Authorizes a maximum amount; the actual charge is determined at settlement time based on resource consumption.                      |

Supported networks include Base, Ethereum, Polygon, Optimism, Arbitrum, Avalanche, Solana, Aptos, Stellar, and Sui. Use `base-sepolia` for testing with free test USDC from the [Circle Faucet ↗](https://faucet.circle.com/).

## Charge for resources

[ HTTP content ](https://developers.cloudflare.com/agents/tools/payments/x402/charge-for-http-content/) Gate APIs, web pages, and files with a Worker proxy 

[ MCP tools ](https://developers.cloudflare.com/agents/tools/payments/x402/charge-for-mcp-tools/) Charge per tool call using paidTool 

## Pay for resources

[ Agents SDK ](https://developers.cloudflare.com/agents/tools/payments/x402/pay-from-agents-sdk/) Wrap MCP clients with withX402Client 

[ Coding tools ](https://developers.cloudflare.com/agents/tools/payments/x402/pay-with-tool-plugins/) OpenCode plugin and Claude Code hook 

## SDKs

| Package     | Install                 | Use                                           |
| ----------- | ----------------------- | --------------------------------------------- |
| x402-hono   | npm install x402-hono   | Hono middleware for Worker servers            |
| @x402/fetch | npm install @x402/fetch | Fetch wrapper with automatic payment handling |
| @x402/evm   | npm install @x402/evm   | EVM payment scheme support                    |
| agents/x402 | Included in agents      | MCP client with x402 payment support          |

## Related

* [x402.org ↗](https://x402.org) — Protocol specification
* [x402 GitHub ↗](https://github.com/x402-foundation/x402) — Open source SDK
* [x402 examples ↗](https://github.com/cloudflare/agents/tree/main/examples) — Complete working code
* [Pay Per Crawl](https://developers.cloudflare.com/ai-crawl-control/features/pay-per-crawl/) — Cloudflare-native monetization

```json
{"@context":"https://schema.org","@type":"WebPage","@id":"https://developers.cloudflare.com/agents/tools/payments/x402/#page","headline":"x402 · Cloudflare Agents docs","description":"Accept and make machine-to-machine payments using the x402 HTTP payment protocol on Cloudflare Workers and the Agents SDK.","url":"https://developers.cloudflare.com/agents/tools/payments/x402/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/payments/","name":"Agentic Payments"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/payments/x402/","name":"x402"}}]}
```

---

---
title: Charge for HTTP content
description: Gate HTTP endpoints with x402 payments using a Cloudflare Worker proxy.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Charge for HTTP content

The x402-proxy template is a Cloudflare Worker that sits in front of any HTTP backend. When a request hits a protected route, the proxy returns a 402 response with payment instructions. After the client pays, the proxy verifies the payment and forwards the request to your origin.

Deploy the x402-proxy template to your Cloudflare account:

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/templates/tree/main/x402-proxy-template)

## Prerequisites

* A [Cloudflare account ↗](https://dash.cloudflare.com/sign-up)
* An HTTP backend to gate
* A wallet address to receive payments

## Configuration

Define protected routes in `wrangler.jsonc`:

```
{  "vars": {    "PAY_TO": "0xYourWalletAddress",    "NETWORK": "base-sepolia",    "PROTECTED_PATTERNS": [      {        "pattern": "/api/premium/*",        "price": "$0.10",        "description": "Premium API access"      }    ]  }}
```

Note

`base-sepolia` is a test network. Change to `base` for production.

## Selective gating with Bot Management

With [Bot Management](https://developers.cloudflare.com/bots/), the proxy can charge crawlers while keeping the site free for humans:

```
{  "pattern": "/content/*",  "price": "$0.10",  "description": "Content access",  "bot_score_threshold": 30,  "except_detection_ids": [117479730]}
```

Requests with a bot score at or below `bot_score_threshold` are directed to the paywall. Use `except_detection_ids` to allowlist specific crawlers by [detection ID](https://developers.cloudflare.com/ai-crawl-control/reference/bots/).

## Deploy

Clone the template, edit `wrangler.jsonc`, and deploy:

Terminal window

```
git clone https://github.com/cloudflare/templatescd templates/x402-proxy-templatenpm installnpx wrangler deploy
```

For full configuration options and Bot Management examples, refer to the [template README ↗](https://github.com/cloudflare/templates/tree/main/x402-proxy-template).

## Custom Worker endpoints

For more control, add x402 middleware directly to your Worker using Hono:

TypeScript

```
import { Hono } from "hono";import { paymentMiddleware } from "x402-hono";
const app = new Hono<{ Bindings: Env }>();
app.use(  paymentMiddleware(    "0xYourWalletAddress" as `0x${string}`,    {      "/premium": {        price: "$0.10",        network: "base-sepolia",        config: { description: "Premium content" },      },    },    { url: "https://x402.org/facilitator" },  ),);
app.get("/premium", (c) => c.json({ message: "Thanks for paying!" }));
export default app;
```

Refer to the [x402 Workers example ↗](https://github.com/cloudflare/agents/tree/main/examples/x402) for a complete implementation.

## Related

* [Pay Per Crawl](https://developers.cloudflare.com/ai-crawl-control/features/pay-per-crawl/) — Native Cloudflare monetization without custom code
* [Charge for MCP tools](https://developers.cloudflare.com/agents/tools/payments/x402/charge-for-mcp-tools/) — Charge per tool call instead of per request
* [x402.org ↗](https://x402.org) — Protocol specification

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/payments/x402/charge-for-http-content/#page","headline":"Charge for HTTP content · Cloudflare Agents docs","description":"Gate HTTP endpoints with x402 payments using a Cloudflare Worker proxy.","url":"https://developers.cloudflare.com/agents/tools/payments/x402/charge-for-http-content/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/payments/","name":"Agentic Payments"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/payments/x402/","name":"x402"}},{"@type":"ListItem","position":6,"item":{"@id":"/agents/tools/payments/x402/charge-for-http-content/","name":"Charge for HTTP content"}}]}
```

---

---
title: Charge for MCP tools
description: Charge per tool call in an MCP server using paidTool.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Charge for MCP tools

The Agents SDK provides `paidTool`, a drop-in replacement for `tool` that adds x402 payment requirements. Clients pay per tool call, and you can mix free and paid tools in the same server.

## Setup

Wrap your `McpServer` with `withX402` and use `paidTool` for tools you want to charge for:

TypeScript

```
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { McpAgent } from "agents/mcp";import { withX402, type X402Config } from "agents/x402";import { z } from "zod";
const X402_CONFIG: X402Config = {  network: "base",  recipient: "0xYourWalletAddress",  facilitator: { url: "https://x402.org/facilitator" }, // Payment facilitator URL  // To learn more about facilitators: https://docs.x402.org/core-concepts/facilitator};
export class PaidMCP extends McpAgent<Env> {  server = withX402(    new McpServer({ name: "PaidMCP", version: "1.0.0" }),    X402_CONFIG,  );
  async init() {    // Paid tool — $0.01 per call    this.server.paidTool(      "square",      "Squares a number",      0.01, // USD      { number: z.number() },      {},      async ({ number }) => {        return { content: [{ type: "text", text: String(number ** 2) }] };      },    );
    // Free tool    this.server.tool(      "echo",      "Echo a message",      { message: z.string() },      async ({ message }) => {        return { content: [{ type: "text", text: message }] };      },    );  }}
```

## Configuration

| Field       | Description                                                |
| ----------- | ---------------------------------------------------------- |
| network     | base for production, base-sepolia for testing              |
| recipient   | Wallet address to receive payments                         |
| facilitator | Payment facilitator URL (use https://x402.org/facilitator) |

## paidTool signature

TypeScript

```
this.server.paidTool(  name, // Tool name  description, // Tool description  price, // Price in USD (e.g., 0.01)  inputSchema, // Zod schema for inputs  annotations, // MCP annotations  handler, // Async function that executes the tool);
```

When a client calls a paid tool without payment, the server returns 402 with payment requirements. The client pays via x402, retries with payment proof, and receives the result.

## Testing

Use `base-sepolia` and get test USDC from the [Circle faucet ↗](https://faucet.circle.com/).

For a complete working example, refer to [x402-mcp on GitHub ↗](https://github.com/cloudflare/agents/tree/main/examples/x402-mcp).

## Related

* [Pay from Agents SDK](https://developers.cloudflare.com/agents/tools/payments/x402/pay-from-agents-sdk/) — Build clients that pay for tools
* [Charge for HTTP content](https://developers.cloudflare.com/agents/tools/payments/x402/charge-for-http-content/) — Gate HTTP endpoints
* [MCP server guide](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) — Build your first MCP server

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/payments/x402/charge-for-mcp-tools/#page","headline":"Charge for MCP tools · Cloudflare Agents docs","description":"Charge per tool call in an MCP server using paidTool.","url":"https://developers.cloudflare.com/agents/tools/payments/x402/charge-for-mcp-tools/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/payments/","name":"Agentic Payments"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/payments/x402/","name":"x402"}},{"@type":"ListItem","position":6,"item":{"@id":"/agents/tools/payments/x402/charge-for-mcp-tools/","name":"Charge for MCP tools"}}]}
```

---

---
title: Pay from Agents SDK
description: Use withX402Client to pay for resources from a Cloudflare Agent.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Pay from Agents SDK

The Agents SDK includes an MCP client that can pay for x402-protected tools. Use it from your Agents or any MCP client connection.

TypeScript

```
import { Agent } from "agents";import { withX402Client } from "agents/x402";import { privateKeyToAccount } from "viem/accounts";
export class MyAgent extends Agent {  // Your Agent definitions...
  async onStart() {    const { id } = await this.mcp.connect(`${this.env.WORKER_URL}/mcp`);    const account = privateKeyToAccount(this.env.MY_PRIVATE_KEY);
    this.x402Client = withX402Client(this.mcp.mcpConnections[id].client, {      network: "base-sepolia",      account,    });  }
  onPaymentRequired(paymentRequirements): Promise<boolean> {    // Your human-in-the-loop confirmation flow...  }
  async onToolCall(toolName: string, toolArgs: unknown) {    // The first parameter is the confirmation callback.    // Set to `null` for the agent to pay automatically.    return await this.x402Client.callTool(this.onPaymentRequired, {      name: toolName,      arguments: toolArgs,    });  }}
```

For a complete working example, see [x402-mcp on GitHub ↗](https://github.com/cloudflare/agents/tree/main/examples/x402-mcp).

## Environment setup

Store your private key securely:

Terminal window

```
# Local development (.dev.vars)MY_PRIVATE_KEY="0x..."
# Productionnpx wrangler secret put MY_PRIVATE_KEY
```

Use `base-sepolia` for testing. Get test USDC from the [Circle faucet ↗](https://faucet.circle.com/).

## Related

* [Charge for MCP tools](https://developers.cloudflare.com/agents/tools/payments/x402/charge-for-mcp-tools/) — Build servers that charge for tools
* [Pay from coding tools](https://developers.cloudflare.com/agents/tools/payments/x402/pay-with-tool-plugins/) — Add payments to OpenCode or Claude Code
* [Human-in-the-loop guide](https://developers.cloudflare.com/agents/concepts/agentic-patterns/human-in-the-loop/) — Implement approval workflows

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/payments/x402/pay-from-agents-sdk/#page","headline":"Pay from Agents SDK · Cloudflare Agents docs","description":"Use withX402Client to pay for resources from a Cloudflare Agent.","url":"https://developers.cloudflare.com/agents/tools/payments/x402/pay-from-agents-sdk/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/payments/","name":"Agentic Payments"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/payments/x402/","name":"x402"}},{"@type":"ListItem","position":6,"item":{"@id":"/agents/tools/payments/x402/pay-from-agents-sdk/","name":"Pay from Agents SDK"}}]}
```

---

---
title: Pay from coding tools
description: Add x402 payment handling to OpenCode and Claude Code.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Pay from coding tools

The following examples show how to add x402 payment handling to AI coding tools. When the tool encounters a 402 response, it pays automatically and retries.

Both examples require:

* A wallet private key (set as `X402_PRIVATE_KEY` environment variable)
* The x402 packages: `@x402/fetch`, `@x402/evm`, and `viem`

## OpenCode plugin

OpenCode plugins expose tools to the agent. To create an `x402-fetch` tool that handles 402 responses, create `.opencode/plugins/x402-payment.ts`:

TypeScript

```
// Use base-sepolia for testing. Get test USDC from https://faucet.circle.com/import type { Plugin } from "@opencode-ai/plugin";import { tool } from "@opencode-ai/plugin";import { x402Client, wrapFetchWithPayment } from "@x402/fetch";import { registerExactEvmScheme } from "@x402/evm/exact/client";import { privateKeyToAccount } from "viem/accounts";
export const X402PaymentPlugin: Plugin = async () => ({  tool: {    "x402-fetch": tool({      description:        "Fetch a URL with x402 payment. Use when webfetch returns 402.",      args: {        url: tool.schema.string().describe("The URL to fetch"),        timeout: tool.schema.number().optional().describe("Timeout in seconds"),      },      async execute(args) {        const privateKey = process.env.X402_PRIVATE_KEY;        if (!privateKey) {          throw new Error("X402_PRIVATE_KEY environment variable is not set.");        }
        // Your human-in-the-loop confirmation flow...        // const approved = await confirmPayment(args.url, estimatedCost);        // if (!approved) throw new Error("Payment declined by user");
        const account = privateKeyToAccount(privateKey as `0x${string}`);        const client = new x402Client();        registerExactEvmScheme(client, { signer: account });        const paidFetch = wrapFetchWithPayment(fetch, client);
        const response = await paidFetch(args.url, {          method: "GET",          signal: args.timeout            ? AbortSignal.timeout(args.timeout * 1000)            : undefined,        });
        if (!response.ok) {          throw new Error(`${response.status} ${response.statusText}`);        }
        return await response.text();      },    }),  },});
```

When the built-in `webfetch` returns a 402, the agent calls `x402-fetch` to retry with payment.

## Claude Code hook

Claude Code hooks intercept tool results. To handle 402s transparently, create a script at `.claude/scripts/handle-x402.mjs`:

JavaScript

```
// Use base-sepolia for testing. Get test USDC from https://faucet.circle.com/import { x402Client, wrapFetchWithPayment } from "@x402/fetch";import { registerExactEvmScheme } from "@x402/evm/exact/client";import { privateKeyToAccount } from "viem/accounts";
const input = JSON.parse(await readStdin());
const haystack = JSON.stringify(input.tool_response ?? input.error ?? "");if (!haystack.includes("402")) process.exit(0);
const url = input.tool_input?.url;if (!url) process.exit(0);
const privateKey = process.env.X402_PRIVATE_KEY;if (!privateKey) {  console.error("X402_PRIVATE_KEY not set.");  process.exit(2);}
try {  // Your human-in-the-loop confirmation flow...  // const approved = await confirmPayment(url);  // if (!approved) process.exit(0);
  const account = privateKeyToAccount(privateKey);  const client = new x402Client();  registerExactEvmScheme(client, { signer: account });  const paidFetch = wrapFetchWithPayment(fetch, client);
  const res = await paidFetch(url, { method: "GET" });  const text = await res.text();
  if (!res.ok) {    console.error(`Paid fetch failed: ${res.status}`);    process.exit(2);  }
  console.log(    JSON.stringify({      hookSpecificOutput: {        hookEventName: "PostToolUse",        additionalContext: `Paid for "${url}" via x402:\n${text}`,      },    }),  );} catch (err) {  console.error(`x402 payment failed: ${err.message}`);  process.exit(2);}
function readStdin() {  return new Promise((resolve) => {    let data = "";    process.stdin.on("data", (chunk) => (data += chunk));    process.stdin.on("end", () => resolve(data));  });}
```

Register the hook in `.claude/settings.json`:

```
{  "hooks": {    "PostToolUse": [      {        "matcher": "WebFetch",        "hooks": [          {            "type": "command",            "command": "node .claude/scripts/handle-x402.mjs",            "timeout": 30          }        ]      }    ]  }}
```

## Related

* [Pay from Agents SDK](https://developers.cloudflare.com/agents/tools/payments/x402/pay-from-agents-sdk/) — Use the Agents SDK for more control
* [Charge for HTTP content](https://developers.cloudflare.com/agents/tools/payments/x402/charge-for-http-content/) — Build the server side
* [Human-in-the-loop guide](https://developers.cloudflare.com/agents/concepts/agentic-patterns/human-in-the-loop/) — Implement approval workflows
* [x402.org ↗](https://x402.org) — Protocol specification

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/payments/x402/pay-with-tool-plugins/#page","headline":"Pay from coding tools · Cloudflare Agents docs","description":"Add x402 payment handling to OpenCode and Claude Code.","url":"https://developers.cloudflare.com/agents/tools/payments/x402/pay-with-tool-plugins/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/payments/","name":"Agentic Payments"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/tools/payments/x402/","name":"x402"}},{"@type":"ListItem","position":6,"item":{"@id":"/agents/tools/payments/x402/pay-with-tool-plugins/","name":"Pay from coding tools"}}]}
```

---

---
title: Sandbox
description: Give agents isolated Linux environments for running code, managing files, and executing commands.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Sandbox

Agents can use [Sandbox](https://developers.cloudflare.com/sandbox/) to run code in isolated container environments. Use Sandbox when an agent needs a real filesystem, shell commands, language runtimes, package installation, or long-lived project state that should not run inside the agent's own Worker isolate.

Sandbox is built on [Cloudflare Containers](https://developers.cloudflare.com/containers/) and exposes a TypeScript API for command execution, file operations, background processes, and service previews.

## When to use Sandbox

Use Sandbox for agents that need to:

* Run untrusted or model-generated code in isolation.
* Execute Python, Node.js, shell commands, or package managers.
* Read, write, and manage project files.
* Run tests, linters, build tools, or data analysis scripts.
* Maintain a workspace across multiple agent turns.

## Basic pattern

Bind the Sandbox Durable Object to your Worker, then access a sandbox from your agent methods with `getSandbox()`.

* [  JavaScript ](#tab-panel-6809)
* [  TypeScript ](#tab-panel-6810)

JavaScript

```
import { Agent, callable } from "agents";import { getSandbox } from "@cloudflare/sandbox";
export { Sandbox } from "@cloudflare/sandbox";
export class CodeAgent extends Agent {  @callable()  async runPython(code) {    const sandbox = getSandbox(this.env.Sandbox, this.name);
    await sandbox.writeFile("/workspace/script.py", code);    const result = await sandbox.exec("python3 /workspace/script.py");
    this.setState({ lastOutput: result.stdout });
    return {      success: result.success,      stdout: result.stdout,      stderr: result.stderr,      exitCode: result.exitCode,    };  }}
```

TypeScript

```
import { Agent, callable } from "agents";import { getSandbox } from "@cloudflare/sandbox";import type { Sandbox } from "@cloudflare/sandbox";
export { Sandbox } from "@cloudflare/sandbox";
type Env = {  Sandbox: DurableObjectNamespace<Sandbox>;};
export class CodeAgent extends Agent<Env, { lastOutput?: string }> {  @callable()  async runPython(code: string) {    const sandbox = getSandbox(this.env.Sandbox, this.name);
    await sandbox.writeFile("/workspace/script.py", code);    const result = await sandbox.exec("python3 /workspace/script.py");
    this.setState({ lastOutput: result.stdout });
    return {      success: result.success,      stdout: result.stdout,      stderr: result.stderr,      exitCode: result.exitCode,    };  }}
```

## Configuration

Configure the Sandbox container, Durable Object binding, and migration in `wrangler.jsonc`.

* [  wrangler.jsonc ](#tab-panel-6807)
* [  wrangler.toml ](#tab-panel-6808)

JSONC

```
{  "containers": [    {      "class_name": "Sandbox",      "image": "./Dockerfile",      "instance_type": "lite",      "max_instances": 1    }  ],  "durable_objects": {    "bindings": [      {        "name": "Sandbox",        "class_name": "Sandbox"      }    ]  },  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": ["Sandbox"]    }  ]}
```

TOML

```
[[containers]]class_name = "Sandbox"image = "./Dockerfile"instance_type = "lite"max_instances = 1
[[durable_objects.bindings]]name = "Sandbox"class_name = "Sandbox"
[[migrations]]tag = "v1"new_sqlite_classes = [ "Sandbox" ]
```

## Sandbox and agent state

Use agent state for user-visible progress and small metadata. Use the sandbox filesystem for workspace files, generated code, package installs, logs, and artifacts.

For long-running sandbox work, pair Sandbox with [durable execution with fibers](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/) or [Workflows](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/) so the agent can recover or report progress if work outlives a single request.

## Related resources

[ Sandbox SDK ](https://developers.cloudflare.com/sandbox/) Full Sandbox documentation for commands, files, sessions, and deployment. 

[ Execute commands ](https://developers.cloudflare.com/sandbox/guides/execute-commands/) Run shell commands in a sandbox environment. 

[ Manage files ](https://developers.cloudflare.com/sandbox/guides/manage-files/) Read, write, upload, and download sandbox files.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/tools/sandbox/#page","headline":"Sandbox · Cloudflare Agents docs","description":"Give agents isolated Linux environments for running code, managing files, and executing commands.","url":"https://developers.cloudflare.com/agents/tools/sandbox/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/tools/","name":"Tools"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/tools/sandbox/","name":"Sandbox"}}]}
```

---

---
title: Harnesses
description: Understand agent harnesses — the loop that controls planning, tool use, and response flow.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Harnesses

A harness is the loop that makes an agent behave like an agent instead of a single model call.

It is responsible for the turn-by-turn work around the model: building the prompt, loading memory, selecting tools, handling tool results, streaming responses, persisting messages, and deciding whether the agent should continue or stop.

You can build this loop yourself on top of the [Agents SDK runtime](https://developers.cloudflare.com/agents/runtime/agents-api/), or use an opinionated harness like [Project Think](https://developers.cloudflare.com/agents/harnesses/think/).

## How harnesses fit

Harnesses sit on top of the Agents SDK runtime:

* **The runtime** gives the agent durable infrastructure: the [Agent class](https://developers.cloudflare.com/agents/runtime/lifecycle/agent-class/), [state](https://developers.cloudflare.com/agents/runtime/lifecycle/state/), [sessions](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/), [routing](https://developers.cloudflare.com/agents/runtime/communication/routing/), [WebSockets](https://developers.cloudflare.com/agents/runtime/communication/websockets/), [scheduling](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/), [fibers](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/), and [observability](https://developers.cloudflare.com/agents/runtime/operations/observability/).
* **The harness** gives the agent behavior: model calls, prompt construction, tool selection, stream handling, memory strategy, and lifecycle hooks.

The runtime answers “where does this agent live and how does it stay durable?” The harness answers “what does this agent do on each turn?”

## Choose an approach

Use a build-your-own harness when you need full control over the model call, message format, tool loop, or UI protocol. This is the right approach when you want to compose low-level APIs directly from the Agents SDK.

Use Project Think when you want an opinionated chat-agent harness with defaults for memory, workspace tools, streaming, lifecycle hooks, sub-agent RPC, and durable chat recovery.

## Current harnesses

[ Project Think ](https://developers.cloudflare.com/agents/harnesses/think/) An opinionated chat agent harness with built-in tools, persistent memory, lifecycle hooks, streaming, and sub-agent RPC. 

## What a harness usually owns

A harness usually owns:

* **Prompt construction** — system prompts, memory, retrieved context, and per-turn instructions.
* **Model execution** — the call to Workers AI, OpenAI, Anthropic, Gemini, or another provider.
* **Tool orchestration** — server tools, client tools, MCP tools, approval flows, and continuation after tool results.
* **Message persistence** — how user, assistant, and tool messages are saved and replayed.
* **Streaming and recovery** — how responses stream to clients and resume after disconnects or Durable Object eviction.
* **Extension points** — hooks before and after turns, steps, tool calls, and recovery events.

## Related resources

[ Agents SDK runtime ](https://developers.cloudflare.com/agents/runtime/agents-api/) Build your own harness directly on the Agent class. 

[ Sessions ](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/) Store conversation context and memory across turns. 

[ Durable execution with fibers ](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/) Recover long-running agent work after Durable Object eviction.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/#page","headline":"Harnesses · Cloudflare Agents docs","description":"Understand agent harnesses — the loop that controls planning, tool use, and response flow.","url":"https://developers.cloudflare.com/agents/harnesses/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}}]}
```

---

---
title: Think
description: Opinionated chat agent framework with built-in tools, persistent memory, lifecycle hooks, streaming, messengers, scheduled tasks, Workflows, and sub-agent RPC.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Think

`@cloudflare/think` lets you build a stateful AI chat agent — one that streams replies, remembers the conversation, and calls tools — by extending a single base class. You provide a model with `getModel()`, and Think wires up the rest of the chat lifecycle for you: the agentic loop (the model calls tools, reads the results, and keeps going until it has an answer), message persistence, streaming, client tools, stream resumption, and extensions — all backed by Durable Object SQLite.

Think works as both a **top-level agent** (WebSocket chat to browser clients via `useAgentChat`) and a **sub-agent** (a child agent that another agent drives over RPC via `chat()`).

New to Cloudflare Agents?

If this is your first agent, start with the [Getting started tutorial](https://developers.cloudflare.com/agents/harnesses/think/getting-started/) for a guided build. For the bigger picture of what agents are and how they run, read [What are agents?](https://developers.cloudflare.com/agents/concepts/what-are-agents/). Think builds on two Cloudflare primitives worth a quick look: [Workers AI](https://developers.cloudflare.com/workers-ai/) provides the model, and each agent instance is a [Durable Object](https://developers.cloudflare.com/durable-objects/) that stores its state. The rest of this section is reference material you can dip into as you need it.

## Quick start

### Install

Terminal window

```
npm install @cloudflare/think @cloudflare/ai-chat agents ai @cloudflare/shell zod workers-ai-provider
```

### Server

* [  JavaScript ](#tab-panel-5771)
* [  TypeScript ](#tab-panel-5772)

JavaScript

```
import { Think } from "@cloudflare/think";import { createWorkersAI } from "workers-ai-provider";import { routeAgentRequest } from "agents";
export class MyAgent extends Think {  getModel() {    return createWorkersAI({ binding: this.env.AI })(      "@cf/moonshotai/kimi-k2.6",    );  }}
export default {  async fetch(request, env) {    return (      (await routeAgentRequest(request, env)) ||      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
import { Think } from "@cloudflare/think";import { createWorkersAI } from "workers-ai-provider";import { routeAgentRequest } from "agents";
export class MyAgent extends Think<Env> {  getModel() {    return createWorkersAI({ binding: this.env.AI })(      "@cf/moonshotai/kimi-k2.6",    );  }}
export default {  async fetch(request: Request, env: Env) {    return (      (await routeAgentRequest(request, env)) ||      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

That is it. Think handles the WebSocket chat protocol, message persistence, the agentic loop, message sanitization, stream resumption, client tool support, and workspace file tools.

### Client

* [  JavaScript ](#tab-panel-5773)
* [  TypeScript ](#tab-panel-5774)

JavaScript

```
import { useAgent } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {  const agent = useAgent({ agent: "MyAgent" });  const { messages, sendMessage, status } = useAgentChat({ agent });
  return (    <div>      {messages.map((msg) => (        <div key={msg.id}>          <strong>{msg.role}:</strong>          {msg.parts.map((part, i) =>            part.type === "text" ? <span key={i}>{part.text}</span> : null,          )}        </div>      ))}
      <form        onSubmit={(e) => {          e.preventDefault();          const input = e.currentTarget.elements.namedItem("input");          sendMessage({ text: input.value });          input.value = "";        }}      >        <input name="input" placeholder="Send a message..." />        <button type="submit">Send</button>      </form>    </div>  );}
```

TypeScript

```
import { useAgent } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {  const agent = useAgent({ agent: "MyAgent" });  const { messages, sendMessage, status } = useAgentChat({ agent });
  return (    <div>      {messages.map((msg) => (        <div key={msg.id}>          <strong>{msg.role}:</strong>          {msg.parts.map((part, i) =>            part.type === "text" ? <span key={i}>{part.text}</span> : null,          )}        </div>      ))}
      <form        onSubmit={(e) => {          e.preventDefault();          const input = e.currentTarget.elements.namedItem(            "input",          ) as HTMLInputElement;          sendMessage({ text: input.value });          input.value = "";        }}      >        <input name="input" placeholder="Send a message..." />        <button type="submit">Send</button>      </form>    </div>  );}
```

### Configuration

* [  wrangler.jsonc ](#tab-panel-5767)
* [  wrangler.toml ](#tab-panel-5768)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": [    "nodejs_compat"  ],  "ai": {    "binding": "AI"  },  "durable_objects": {    "bindings": [      {        "class_name": "MyAgent",        "name": "MyAgent"      }    ]  },  "migrations": [    {      "new_sqlite_classes": [        "MyAgent"      ],      "tag": "v1"    }  ]}
```

TOML

```
# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = ["nodejs_compat"]
[ai]binding = "AI"
[[durable_objects.bindings]]class_name = "MyAgent"name = "MyAgent"
[[migrations]]new_sqlite_classes = ["MyAgent"]tag = "v1"
```

## Think vs AIChatAgent

Both Think and [AIChatAgent](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/) extend `Agent` and speak the same `cf_agent_chat_*` WebSocket protocol. They serve different goals.

**AIChatAgent** is a protocol adapter. You override `onChatMessage` and are responsible for calling `streamText`, wiring tools, converting messages, and returning a `Response`. AIChatAgent handles the plumbing — message persistence, streaming, abort, resume — but the LLM call is entirely your concern.

**Think** is an opinionated framework. It makes decisions for you: `getModel()` returns the model, `getSystemPrompt()` or `configureSession()` sets the prompt, `getTools()` returns tools. The default `onChatMessage` runs the complete agentic loop. You override individual pieces, not the whole pipeline.

| Concern                | AIChatAgent                                                      | Think                                                               |
| ---------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------- |
| **Minimal subclass**   | \~15 lines (wire streamText \+ tools + system prompt + response) | 3 lines (getModel() only)                                           |
| **Storage**            | Flat SQL table                                                   | Session: tree-structured messages, context blocks, compaction, FTS5 |
| **Regeneration**       | Destructive (old response deleted)                               | Non-destructive branching (old responses preserved)                 |
| **Context management** | Manual                                                           | Context blocks with LLM-writable persistent memory                  |
| **Sub-agent RPC**      | Not built in                                                     | chat() with StreamCallback                                          |
| **Programmatic turns** | saveMessages()                                                   | saveMessages(), submitMessages(), continueLastTurn()                |
| **Compaction**         | maxPersistedMessages (deletes oldest)                            | Non-destructive summaries via overlays                              |
| **Search**             | Not available                                                    | FTS5 full-text search per-session and cross-session                 |

### When to use AIChatAgent

* You need full control over the LLM call (RAG, multi-model, custom streaming)
* You want the `Response` return type for HTTP middleware or testing
* You are building a simple chatbot with no memory requirements

### When to use Think

* You want to ship fast (3-line subclass with everything wired)
* You need persistent memory (context blocks the model can read and write)
* You need long conversations (non-destructive compaction)
* You need conversation search (FTS5)
* You are building a sub-agent system (parent-child RPC with streaming)
* You need proactive agents (programmatic turns from scheduled tasks or webhooks)
* You need durable async submission for webhook or RPC callers

## Choose a turn API

Think has several ways to start or continue a turn. They all funnel through one public entry point — `runTurn(options)` — and the older methods remain as convenience shortcuts.

### runTurn()

Experimental

`runTurn()` is stable in shape, but may evolve before Think graduates out of experimental.

`runTurn()` is the unified turn-admission API. One method, three modes, selected by `options.mode`:

| Mode             | Use when                                                     | Returns                       | Shortcut for     |
| ---------------- | ------------------------------------------------------------ | ----------------------------- | ---------------- |
| "wait" (default) | The caller can block until the model response is finished    | Promise<TurnResult>           | saveMessages()   |
| "submit"         | The caller needs fast, durable acceptance and a later status | Promise<SubmitMessagesResult> | submitMessages() |
| "stream"         | The caller wants the response streamed to a callback (RPC)   | Promise<void>                 | chat()           |

The `input` accepts a string, a `UIMessage`, an array of messages, or — in `wait` and `stream` modes — a function `(current) => UIMessage[]` evaluated at admission. (`submit` does not accept function input.)

* [  JavaScript ](#tab-panel-5775)
* [  TypeScript ](#tab-panel-5776)

JavaScript

```
export class Assistant extends Think {  async examples(inboundEventId) {    // wait — block for the result    const result = await this.runTurn({ input: "Summarize the latest thread" });    if (result.status === "completed") {      // result.message is the assistant message; result.continuation is false    }
    // submit — durable acceptance, check status later    const submission = await this.runTurn({      mode: "submit",      input: "Process this webhook",      idempotencyKey: inboundEventId, // dedupe; safe to retry    });    // submission.accepted is true on first accept; submission.status is "pending"
    // stream — drive a callback (the same surface as chat())    await this.runTurn({      mode: "stream",      input: "Stream me",      callback: {        onStart({ requestId }) {},        onEvent(json) {}, // UIMessageChunk JSON        onDone() {},        onError(error) {},      },    });
    // continuation — continue the last assistant turn instead of sending input    await this.runTurn({ continuation: true });  }}
```

TypeScript

```
export class Assistant extends Think<Env> {  async examples(inboundEventId: string) {    // wait — block for the result    const result = await this.runTurn({ input: "Summarize the latest thread" });    if (result.status === "completed") {      // result.message is the assistant message; result.continuation is false    }
    // submit — durable acceptance, check status later    const submission = await this.runTurn({      mode: "submit",      input: "Process this webhook",      idempotencyKey: inboundEventId, // dedupe; safe to retry    });    // submission.accepted is true on first accept; submission.status is "pending"
    // stream — drive a callback (the same surface as chat())    await this.runTurn({      mode: "stream",      input: "Stream me",      callback: {        onStart({ requestId }) {},        onEvent(json) {}, // UIMessageChunk JSON        onDone() {},        onError(error) {},      },    });
    // continuation — continue the last assistant turn instead of sending input    await this.runTurn({ continuation: true });  }}
```

Key behaviors:

* **Blocking modes cannot nest.** Calling `wait`/`stream`/`continuation` (or the equivalent shortcut) from _inside_ an active turn — for example, from a tool's `execute` — throws, because it would deadlock the turn queue. From inside a turn, use `runTurn({ mode: "submit" })` (durable, runs after the current turn frees the queue) or [addMessages()](#add-messages-without-a-turn) (transcript only, no inference).
* **`submit` is idempotent.** Pass `submissionId` and/or `idempotencyKey`; re-submitting a known key returns the existing record with `accepted: false` instead of starting a second turn. See [Programmatic submissions](https://developers.cloudflare.com/agents/harnesses/think/programmatic-submissions/).
* **Recovery-safe.** When `chatRecovery` is enabled, the `wait`, `stream`, and drained `submit` paths all run inference inside a recovery fiber, so an interrupted turn resumes after eviction.

`runTurn` is exported alongside its option and result types: `RunTurnOptions`, `RunTurnWait`, `RunTurnSubmit`, `RunTurnStream`, `TurnInputMessages`, and `TurnResult`.

### Pick a shortcut

The table below maps each scenario to the most direct call. Each shortcut has an unchanged signature; reach for them when you want the narrower surface, or use `runTurn()` when you want one mental model.

| Use case                                                       | API                                           |
| -------------------------------------------------------------- | --------------------------------------------- |
| A browser user sends chat messages                             | useAgentChat over the WebSocket chat protocol |
| Server code can wait for the model response                    | saveMessages()                                |
| Server code needs fast durable acceptance and later status     | submitMessages()                              |
| Code should create recurring prompt-driven turns or handlers   | getScheduledTasks()                           |
| Parent code needs direct streaming RPC to a specific child     | subAgent(...).chat()                          |
| A parent delegates work to a retained child agent              | agentTool() or runAgentTool()                 |
| Surround a turn with idempotent app-owned side effects         | startFiber()                                  |
| Coordinate multi-step durable orchestration                    | Workflows                                     |
| Add context or messages without starting a model turn          | addMessages()                                 |
| Advanced subclass or recovery code continues an assistant turn | continueLastTurn()                            |

Use `saveMessages()` when the caller owns the trigger and can wait for the turn to finish. Use [submitMessages()](https://developers.cloudflare.com/agents/harnesses/think/programmatic-submissions/) when timeout ambiguity would make retries unsafe.

### Add messages without a turn

Use `addMessages()` to write to the transcript **without** starting a model turn — for importing prior history or injecting background context the next turn should see:

* [  JavaScript ](#tab-panel-5769)
* [  TypeScript ](#tab-panel-5770)

JavaScript

```
export class Assistant extends Think {  async importContext() {    await this.addMessages([      {        id: crypto.randomUUID(),        role: "user",        parts: [{ type: "text", text: "Imported context" }],      },    ]);  }}
```

TypeScript

```
export class Assistant extends Think<Env> {  async importContext() {    await this.addMessages([      {        id: crypto.randomUUID(),        role: "user",        parts: [{ type: "text", text: "Imported context" }],      },    ]);  }}
```

`addMessages()` appends (or upserts) into the Session tree:

* It does **not** run inference and does **not** enter the turn queue, so it is safe to call from inside a tool's `execute` without deadlocking.
* Array entries are appended **linearly** (each attaches under the previous one), so imported history stays a single path. By default the first message attaches to the latest committed leaf; pass `parentId` to attach elsewhere, or `null` for a root message.
* Appends are **idempotent by message id**. Pass `{ mode: "upsert" }` to update an existing message in place instead.

The supported pattern is "add context, then run a turn": call `addMessages()`, then `runTurn()`.

Use `chat()` for low-level parent-to-child streaming when your code owns forwarding, cancellation, and replay policy. Use [Agents as tools](https://developers.cloudflare.com/agents/runtime/execution/agent-tools/) when a parent model or workflow delegates to a child agent and you want retained child runs, event replay, abort bridging, and UI drill-in.

Use [startFiber()](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/#startfiber) outside Think when the durable unit is an application job around a turn: accepting a webhook once, restoring a serialized channel or thread target, posting a visible reply, or recording app-level recovery policy. Think submissions own conversation admission and turn serialization; managed fibers own external job acceptance, idempotent side effects, and application recovery.

## In this section

[ Getting started ](https://developers.cloudflare.com/agents/harnesses/think/getting-started/) Build a Think agent step by step. 

[ Configuration ](https://developers.cloudflare.com/agents/harnesses/think/configuration/) Configuration overrides, dynamic configuration, and Session integration. 

[ Tools ](https://developers.cloudflare.com/agents/harnesses/think/tools/) Workspace tools, code execution, browser tools, and extensions. 

[ Actions ](https://developers.cloudflare.com/agents/harnesses/think/actions/) Server actions with idempotency, approvals, authorization, and reply attachments. 

[ Channels ](https://developers.cloudflare.com/agents/harnesses/think/channels/) Per-channel policy, channel selection, and out-of-band notices. 

[ Lifecycle hooks ](https://developers.cloudflare.com/agents/harnesses/think/lifecycle-hooks/) beforeTurn, beforeStep, onStepFinish, onChatResponse, and more. 

[ Client tools ](https://developers.cloudflare.com/agents/harnesses/think/client-tools/) Browser-side tools, approvals, and concurrency. 

[ Messengers ](https://developers.cloudflare.com/agents/harnesses/think/messengers/) Receive and reply to Chat SDK messenger webhooks. 

[ Scheduled tasks ](https://developers.cloudflare.com/agents/harnesses/think/scheduled-tasks/) Declarative recurring prompts and handlers. 

[ Workflows ](https://developers.cloudflare.com/agents/harnesses/think/workflows/) Durable model-driven reasoning steps inside Cloudflare Workflows. 

[ Sub-agent RPC ](https://developers.cloudflare.com/agents/harnesses/think/sub-agents/) chat() streaming, saveMessages, continueLastTurn, and abort. 

[ Programmatic submissions ](https://developers.cloudflare.com/agents/harnesses/think/programmatic-submissions/) Durable turn admission for webhooks and RPC callers. 

[ Durable recovery ](https://developers.cloudflare.com/agents/harnesses/think/recovery/) Chat recovery, stream-stall watchdog, and stability detection. 

[ Agent Skills ](https://developers.cloudflare.com/agents/runtime/execution/agent-skills/) On-demand instructions, resources, and scripts via getSkills(). 

## Acknowledgments

Think's design is inspired by [Pi ↗](https://pi.dev).

## Example

[ Assistant example ](https://github.com/cloudflare/agents/tree/main/examples/assistant) Explore a multi-session Think assistant with sub-agent routing, shared workspace, MCP, chat recovery, and GitHub OAuth. 

## Related

* [Sessions](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/) — context blocks, compaction, search, multi-session (the storage layer Think builds on)
* [Sub-agents](https://developers.cloudflare.com/agents/runtime/execution/sub-agents/) — `subAgent()`, `abortSubAgent()`, `deleteSubAgent()` (the base Agent methods for spawning children)
* [Chat agents](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/) — `AIChatAgent` for when you need full control over the LLM call
* [Long-running agents](https://developers.cloudflare.com/agents/concepts/agentic-patterns/long-running-agents/) — sub-agent delegation patterns for multi-week agent lifetimes
* [Durable execution](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/) — `runFiber()` and crash recovery (used by `chatRecovery`)
* [Browse the web](https://developers.cloudflare.com/agents/tools/browser/) — full CDP helper API reference

```json
{"@context":"https://schema.org","@type":"WebPage","@id":"https://developers.cloudflare.com/agents/harnesses/think/#page","headline":"Think · Cloudflare Agents docs","description":"Opinionated chat agent framework with built-in tools, persistent memory, lifecycle hooks, streaming, messengers, scheduled tasks, Workflows, and sub-agent RPC.","url":"https://developers.cloudflare.com/agents/harnesses/think/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-26","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}}]}
```

---

---
title: Actions
description: Server-side Think tools with idempotency, human approvals, authorization, and reply attachments.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Actions

Experimental

The Actions API surface may evolve before Think graduates out of experimental.

Actions are server-side tools with batteries included. Where a plain AI SDK `tool()` is just a description, a schema, and an `execute` function, an `action()` adds the things that are tedious and dangerous to get right by hand for a tool that has real side effects:

* **Idempotency** — a durable ledger replays a settled result by a stable key instead of re-running the side effect on a recovery retry.
* **Approvals** — gate a call behind a human, either inline (the turn waits) or durably (the turn parks and resumes later, even from a dashboard with no live socket).
* **Authorization** — declare the permissions a call requires and grant them per-turn.
* **Reply attachments** — record advisory delivery metadata (a drafted email, a card, a voice note) without changing what the model sees.

Actions compile into Think tools, so the model calls them exactly like any other tool. Return them from `getActions()`; Think merges them into the tool set alongside `getTools()`, workspace tools, extensions, and MCP tools.

## Define an action

Use the `action()` descriptor factory and return a map of actions from `getActions()`. The map key is the tool name the model sees (unless you set `name`). The `execute` input type is inferred from `inputSchema`:

* [  JavaScript ](#tab-panel-5789)
* [  TypeScript ](#tab-panel-5790)

JavaScript

```
import { Think, action } from "@cloudflare/think";import { z } from "zod";
export class Support extends Think {  getActions() {    return {      refundOrder: action({        description: "Refund a customer order.",        inputSchema: z.object({          orderId: z.string(),          amountCents: z.number().int().positive(),        }),        execute: async ({ orderId, amountCents }, ctx) => {          const result = await refund(orderId, amountCents);          return { refundId: result.id, status: result.status };        },      }),    };  }}
```

TypeScript

```
import { Think, action } from "@cloudflare/think";import { z } from "zod";
export class Support extends Think<Env> {  getActions() {    return {      refundOrder: action({        description: "Refund a customer order.",        inputSchema: z.object({          orderId: z.string(),          amountCents: z.number().int().positive(),        }),        execute: async ({ orderId, amountCents }, ctx) => {          const result = await refund(orderId, amountCents);          return { refundId: result.id, status: result.status };        },      }),    };  }}
```

The `execute` callback receives the validated input and an `ActionContext`:

TypeScript

```
type ActionContext = {  agent: Think;  env: Cloudflare.Env;  requestId: string;  toolCallId: string;  messages: ReadonlyArray<ModelMessage>;  signal: AbortSignal; // aborts on turn cancel or after `timeoutMs`  attachReply(attachment: ReplyAttachment): void;};
```

The action output is normalized to JSON and truncated before it is shown to the model (long outputs are capped). Anything thrown from `execute` becomes a structured `{ error: { name, message } }` tool result rather than crashing the turn. Each action has a default timeout of 30 seconds; override it per action with `timeoutMs`.

### Actions versus plain tools

A plain `tool()` from `getTools()` still works and is the right choice for a read-only or trivial tool. Reach for `action()` when a tool has side effects you must not run twice, needs human approval, or needs declarative authorization — the ledger, approval descriptors, default timeout, and structured error mapping only apply to actions.

## Idempotency and the action ledger

When an action declares an `idempotencyKey`, Think records the settled result in a durable ledger keyed by `action:<name>:<key>`. If the same key is seen again — on a recovery retry, a reconnect, or a duplicate inbound event — Think returns the stored result **without** re-running `execute`, so the side effect happens at most once on the happy path.

* [  JavaScript ](#tab-panel-5777)
* [  TypeScript ](#tab-panel-5778)

JavaScript

```
const chargeInvoice = action({  description: "Charge an invoice.",  inputSchema: z.object({ invoiceId: z.string() }),  // Use a stable domain identifier — never a timestamp, request id, or random value.  idempotencyKey: ({ input }) => `invoice:${input.invoiceId}`,  execute: async ({ invoiceId }) => charge(invoiceId),});
```

TypeScript

```
const chargeInvoice = action({  description: "Charge an invoice.",  inputSchema: z.object({ invoiceId: z.string() }),  // Use a stable domain identifier — never a timestamp, request id, or random value.  idempotencyKey: ({ input }) => `invoice:${input.invoiceId}`,  execute: async ({ invoiceId }) => charge(invoiceId),});
```

`idempotencyKey` is a string, or a function `({ input, ctx }) => string`. Choose a key that survives recovery retries — an order id, an inbound event id — and not a value that changes per attempt. An action with no `idempotencyKey` falls back to a per-`toolCallId` key, which only deduplicates within the same tool call, not across retries.

### Pending rows and the retry lease

A ledger row is written as `pending` before `execute` runs and flipped to `settled` on success (a thrown or timed-out `execute` deletes the row so the call can be retried cleanly). If the isolate dies mid-execute, the row is left `pending`. By default such a stale row is reclaimed and the action re-run once the row is older than `actionLedgerPendingRetryLeaseMs` (default 5 minutes) — **but only for actions that declare an explicit `idempotencyKey`**, since that key is your assertion that re-running the keyed side effect is safe. A fresh pending row (or one without an explicit key) instead returns an `ActionPendingError` result so the model does not blindly retry an unknown state. Set `actionLedgerPendingRetryLeaseMs = false` to disable reclaim entirely and always surface `ActionPendingError` for a stale row.

## Approvals

Gate an action behind a human with `approval`. There are two mechanisms, selected by `kind`.

### Approval-gated (the turn waits)

The default when you set `approval` without a `kind`. The action compiles to a tool with the AI SDK `needsApproval` flag: the stream pauses with an `approval-requested` part, the client approves or rejects, and the turn continues inline. `execute` runs only after approval.

* [  JavaScript ](#tab-panel-5779)
* [  TypeScript ](#tab-panel-5780)

JavaScript

```
const deleteAccount = action({  description: "Permanently delete a user account.",  inputSchema: z.object({ userId: z.string() }),  approval: true, // or ({ input }) => input.userId !== currentUser  approvalSummary: "Delete an account",  approvalRisk: "high",  execute: async ({ userId }) => deleteAccount(userId),});
```

TypeScript

```
const deleteAccount = action({  description: "Permanently delete a user account.",  inputSchema: z.object({ userId: z.string() }),  approval: true, // or ({ input }) => input.userId !== currentUser  approvalSummary: "Delete an account",  approvalRisk: "high",  execute: async ({ userId }) => deleteAccount(userId),});
```

`approval` is a boolean or a predicate `({ input, ctx }) => boolean`, so you can require approval only for risky inputs. `approvalSummary` and `approvalRisk` (`"low" | "medium" | "high"`) populate the approval descriptor your UI renders.

### Durable-pause (the turn parks and resumes later)

Set `kind: "durable-pause"` when approval may take minutes or days and you do not want to hold a connection open. The action parks into a durable store and the turn ends; `execute` does not run yet. Resume later — from anywhere, including a dashboard with no live WebSocket — with `approveExecution()` or `rejectExecution()`:

* [  JavaScript ](#tab-panel-5781)
* [  TypeScript ](#tab-panel-5782)

JavaScript

```
const deploy = action({  description: "Deploy to production.",  inputSchema: z.object({ ref: z.string() }),  kind: "durable-pause",  approvalSummary: "Deploy to production",  approvalRisk: "high",  permissions: ["deploy:run"],  execute: async ({ ref }) => deploy(ref),});
```

TypeScript

```
const deploy = action({  description: "Deploy to production.",  inputSchema: z.object({ ref: z.string() }),  kind: "durable-pause",  approvalSummary: "Deploy to production",  approvalRisk: "high",  permissions: ["deploy:run"],  execute: async ({ ref }) => deploy(ref),});
```

* [  JavaScript ](#tab-panel-5783)
* [  TypeScript ](#tab-panel-5784)

JavaScript

```
// List everything waiting on a human (cold-load reconciliation):const pending = await agent.pendingApprovals();// [{ executionId, source: "action" | "codemode", descriptor }]
// Approve or reject by execution id (idempotent — a second call is a no-op):await agent.approveExecution(executionId);await agent.rejectExecution(executionId, "Not this release");
```

TypeScript

```
// List everything waiting on a human (cold-load reconciliation):const pending = await agent.pendingApprovals();// [{ executionId, source: "action" | "codemode", descriptor }]
// Approve or reject by execution id (idempotent — a second call is a no-op):await agent.approveExecution(executionId);await agent.rejectExecution(executionId, "Not this release");
```

`approveExecution()` runs `execute` once and auto-continues the turn even if no client is connected; `rejectExecution()` resolves the action without running it. `pendingApprovals()` merges parked actions and paused [Codemode](https://developers.cloudflare.com/agents/tools/codemode/) executions, so a single approval UI can drive both. (`durable-pause` requires an `approval` policy — an action that would never park is rejected at definition time.)

Both approval-gated and durable-pause parts carry a stable `ActionApprovalDescriptor` (`{ requestId, toolCallId, action, summary, input, permissions, risk, kind }`) so your UI has everything it needs to render the prompt.

## Authorization

Declare the permissions an action requires with `permissions`, then grant them per turn. By default every turn is fully authorized, so authorization is opt-in.

* [  JavaScript ](#tab-panel-5785)
* [  TypeScript ](#tab-panel-5786)

JavaScript

```
const refundOrder = action({  description: "Refund a customer order.",  inputSchema: z.object({ orderId: z.string() }),  permissions: ["billing:refund"], // or ({ input }) => [...]  execute: async ({ orderId }) => refund(orderId),});
```

TypeScript

```
const refundOrder = action({  description: "Refund a customer order.",  inputSchema: z.object({ orderId: z.string() }),  permissions: ["billing:refund"], // or ({ input }) => [...]  execute: async ({ orderId }) => refund(orderId),});
```

Override `authorizeTurn()` to decide, once per turn, which permissions are granted. Returning a list narrows the grant; any action requiring a permission outside the set is denied with a structured `ActionAuthorizationError` (the model never calls `execute`):

* [  JavaScript ](#tab-panel-5787)
* [  TypeScript ](#tab-panel-5788)

JavaScript

```
export class Support extends Think {  authorizeTurn(ctx) {    const role = ctx.body?.role;    if (role === "admin") return true; // full grant (the default)    return { allowed: true, grantedPermissions: ["billing:read"] };  }}
```

TypeScript

```
export class Support extends Think<Env> {  override authorizeTurn(ctx: TurnContext): ActionAuthorizationDecision {    const role = (ctx.body as { role?: string })?.role;    if (role === "admin") return true; // full grant (the default)    return { allowed: true, grantedPermissions: ["billing:read"] };  }}
```

`authorizeTurn()` returns `true` (full grant), `false` (deny all), or `{ allowed, reason?, grantedPermissions? }`. For per-call logic, override `authorizeAction(ctx)` instead — it receives the action name, kind, input, and required and granted permissions.

## Reply attachments

An action can record advisory delivery metadata for the turn — a drafted email, a card, a voice note — with `ctx.attachReply()`. Attachments never change the tool output the model sees; they ride alongside the response for your delivery layer to render.

* [  JavaScript ](#tab-panel-5791)
* [  TypeScript ](#tab-panel-5792)

JavaScript

```
const draftReply = action({  description: "Draft an email reply.",  inputSchema: z.object({ to: z.string(), subject: z.string() }),  execute: async ({ to, subject }, ctx) => {    ctx.attachReply({ type: "email_draft", to: [to], subject });    return { drafted: true };  },});
```

TypeScript

```
const draftReply = action({  description: "Draft an email reply.",  inputSchema: z.object({ to: z.string(), subject: z.string() }),  execute: async ({ to, subject }, ctx) => {    ctx.attachReply({ type: "email_draft", to: [to], subject });    return { drafted: true };  },});
```

Read the attachments after the turn from the `onChatResponse()` hook, or from the `replyAttachments(requestId?)` getter:

* [  JavaScript ](#tab-panel-5793)
* [  TypeScript ](#tab-panel-5794)

JavaScript

```
export class Support extends Think {  async onChatResponse(result) {    for (const attachment of result.attachments ?? []) {      // attachment.type === "email_draft" | "card" | "voice_note" | custom    }  }}
```

TypeScript

```
export class Support extends Think<Env> {  override async onChatResponse(result: ChatResponseResult) {    for (const attachment of result.attachments ?? []) {      // attachment.type === "email_draft" | "card" | "voice_note" | custom    }  }}
```

Attachments are JSON-normalized and deep-copied on read, capped per turn, and discarded if the `execute` that recorded them fails. A ledger replay does not re-fire attachments (the side effect already happened), and `attachReply()` is a no-op when called from a `permissions`, `approval`, or `idempotencyKey` callback — record attachments from `execute`.

A built-in `ReplyAttachment` covers `email_draft`, `card`, and `voice_note`; any `{ type: string; ... }` shape is allowed for custom delivery. Override [renderAttachment()](https://developers.cloudflare.com/agents/harnesses/think/channels/#deliver-out-of-band) to turn an attachment into a channel notice.

## Reference

### `action(config)`

| Field           | Type                                                           | Required        | Default       | Description                                                     |                                                                                 |
| --------------- | -------------------------------------------------------------- | --------------- | ------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| description     | string                                                         | Yes             | —             | Tool description shown to the model.                            |                                                                                 |
| inputSchema     | FlexibleSchema (Zod or AI SDK jsonSchema)                      | Yes             | —             | Validates and types the execute input.                          |                                                                                 |
| execute         | (input, ctx) => Output \| Promise<Output>                      | Yes             | —             | The action body. Receives validated input and an ActionContext. |                                                                                 |
| name            | string                                                         | No              | map key       | Overrides the tool name.                                        |                                                                                 |
| idempotencyKey  | string \| ({ input, ctx }) => string                           | No              | per tool call | Stable key for ledger replay. Use a domain identifier.          |                                                                                 |
| permissions     | readonly string\[\] \| ({ input, ctx }) => readonly string\[\] | No              | none          | Permissions this call requires (see Authorization).             |                                                                                 |
| approval        | boolean \| ({ input, ctx }) => boolean                         | No              | none          | Gate the call behind a human.                                   |                                                                                 |
| approvalSummary | string                                                         | No              | description   | Human-readable summary in the approval descriptor.              |                                                                                 |
| approvalRisk    | "low" \| "medium"                                              | "high"          | No            | —                                                               | Risk hint in the approval descriptor.                                           |
| kind            | "server" \| "approval-gated"                                   | "durable-pause" | No            | inferred                                                        | approval-gated when approval is set, else server; set durable-pause explicitly. |
| timeoutMs       | number                                                         | No              | 30000         | Per-action execution timeout (also drives ctx.signal).          |                                                                                 |

### Hooks and methods on the agent

| Member                                | Description                                                                      |
| ------------------------------------- | -------------------------------------------------------------------------------- |
| getActions()                          | Return the action descriptors to compile into tools.                             |
| authorizeTurn(ctx)                    | Decide granted permissions once per turn. Defaults to full grant.                |
| authorizeAction(ctx)                  | Decide authorization per action call. Defaults to checking authorizeTurn grants. |
| pendingApprovals(executionId?)        | List parked actions and paused Codemode executions awaiting approval.            |
| approveExecution(executionId)         | Approve a parked execution; runs execute and auto-continues the turn.            |
| rejectExecution(executionId, reason?) | Reject a parked execution without running it.                                    |
| replyAttachments(requestId?)          | Read the advisory attachments recorded during a turn.                            |
| actionLedgerPendingRetryLeaseMs       | Stale-pending reclaim window (default 300000; false to disable).                 |

## Related

* [Tools](https://developers.cloudflare.com/agents/harnesses/think/tools/) — workspace tools, code execution, and extensions.
* [Human in the loop](https://developers.cloudflare.com/agents/concepts/agentic-patterns/human-in-the-loop/) — the approval flow end to end.
* [Channels](https://developers.cloudflare.com/agents/harnesses/think/channels/) — deliver attachments and out-of-band notices.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/think/actions/#page","headline":"Actions · Cloudflare Agents docs","description":"Server-side Think tools with idempotency, human approvals, authorization, and reply attachments.","url":"https://developers.cloudflare.com/agents/harnesses/think/actions/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-26","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/harnesses/think/actions/","name":"Actions"}}]}
```

---

---
title: Channels
description: Apply per-channel policy, select a channel on a turn, and deliver out-of-band notices across web, messenger, voice, and custom surfaces.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Channels

Experimental

The Channels API surface may evolve before Think graduates out of experimental.

A channel is a surface a Think agent talks over: the browser WebSocket, a messenger webhook (Telegram, Slack, and so on), voice, or your own custom transport. Channels generalize [messengers](https://developers.cloudflare.com/agents/harnesses/think/messengers/) into one vocabulary so you can apply per-channel policy (a different system prompt, a narrowed tool set, a step cap) and deliver out-of-band notices, regardless of the surface a turn arrived on.

Every Think agent always has an implicit `web` channel (the WebSocket chat your browser clients use). You declare additional channels — and override the `web` policy — with `configureChannels()`. Messengers returned from `getMessengers()` are automatically absorbed as `messenger` channels, so existing messenger apps keep working unchanged.

## Configure channels

Override `configureChannels()` to return a map of channel id to `ChannelDefinition`. The id is how you select the channel on a turn:

* [  JavaScript ](#tab-panel-5801)
* [  TypeScript ](#tab-panel-5802)

JavaScript

```
import { Think, messengerChannel } from "@cloudflare/think";import { telegram } from "@chat-adapter/telegram";
export class Assistant extends Think {  configureChannels() {    return {      // Override policy for the built-in web channel.      web: {        kind: "web",        ingress: { transport: "websocket" },        instructions: "You are chatting in a web app. Use markdown freely.",      },      // A voice channel with tighter limits.      voice: {        kind: "voice",        ingress: { transport: "voice" },        instructions: "Keep replies short and speakable. No markdown.",        maxTurns: 3,      },      // A messenger channel (Chat SDK webhook).      telegram: messengerChannel(        telegram({          /* adapter config */        }),      ),    };  }}
```

TypeScript

```
import { Think, messengerChannel } from "@cloudflare/think";import { telegram } from "@chat-adapter/telegram";
export class Assistant extends Think<Env> {  configureChannels() {    return {      // Override policy for the built-in web channel.      web: {        kind: "web",        ingress: { transport: "websocket" },        instructions: "You are chatting in a web app. Use markdown freely.",      },      // A voice channel with tighter limits.      voice: {        kind: "voice",        ingress: { transport: "voice" },        instructions: "Keep replies short and speakable. No markdown.",        maxTurns: 3,      },      // A messenger channel (Chat SDK webhook).      telegram: messengerChannel(        telegram({          /* adapter config */        }),      ),    };  }}
```

A `ChannelDefinition` has these fields:

| Field        | Type                                                              | Description                                                                                                        |                                                           |                       |
| ------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- | --------------------- |
| kind         | "web" \| "messenger"                                              | "voice"                                                                                                            | "custom"                                                  | The surface category. |
| ingress      | { transport: "websocket" \| "voice" } or a webhook messenger spec | How turns arrive. messengerChannel() builds the webhook form for you.                                              |                                                           |                       |
| instructions | string \| (ctx: ChannelContext) => string                         | Promise<string>                                                                                                    | Prepended to the system prompt for turns on this channel. |                       |
| tools        | (all: ToolSet) => ToolSet                                         | Narrow the assembled tool set for this channel (filter only — it cannot add tools).                                |                                                           |                       |
| maxTurns     | number                                                            | Per-channel cap on model steps for a turn.                                                                         |                                                           |                       |
| capabilities | ChannelCapabilities                                               | Surface capabilities (streaming, message editing). Defaulted for web.                                              |                                                           |                       |
| conversation | messenger conversation mode or resolver                           | Messenger thread routing (see [Messengers](https://developers.cloudflare.com/agents/harnesses/think/messengers/)). |                                                           |                       |
| delivery     | channel delivery policy                                           | Messenger delivery policy.                                                                                         |                                                           |                       |

Use the `defineChannels()` helper for type inference, and `messengerChannel()` to wrap a Chat SDK adapter definition as a `kind: "messenger"` channel.

### Channel kinds

| Kind      | Ingress                         | Notes                                                                                       |
| --------- | ------------------------------- | ------------------------------------------------------------------------------------------- |
| web       | { transport: "websocket" }      | Always present. Declare it in configureChannels() only to set policy; you cannot remove it. |
| messenger | webhook (messengerChannel(...)) | Fed into the messenger runtime. Equivalent to a getMessengers() entry.                      |
| voice     | { transport: "voice" }          | Applies policy and turn context; out-of-band delivery is not yet wired.                     |
| custom    | app-defined                     | For your own transport. Same delivery limitations as voice today.                           |

## Per-channel policy

Channel policy is applied as an **overridable default** before [beforeTurn](https://developers.cloudflare.com/agents/harnesses/think/lifecycle-hooks/) runs, so a `beforeTurn` override still wins:

* `instructions` is prepended to the base system prompt for the turn.
* `tools` filters the assembled tool set (it can only remove tools — the `getTools()` seam adds them).
* `maxTurns` caps model steps: `beforeTurn`'s `maxSteps` wins, then the channel `maxTurns`, then the instance `maxSteps` default.

## Select a channel on a turn

Pass `channel` to [runTurn()](https://developers.cloudflare.com/agents/harnesses/think/#runturn) (or `chat()`) to run a turn on a specific channel. The channel id is stamped onto the user message, so a continued or recovered turn re-resolves the same channel and re-applies its policy:

* [  JavaScript ](#tab-panel-5795)
* [  TypeScript ](#tab-panel-5796)

JavaScript

```
export class Assistant extends Think {  async speak() {    await this.runTurn({ input: "Read this out loud", channel: "voice" });  }}
```

TypeScript

```
export class Assistant extends Think<Env> {  async speak() {    await this.runTurn({ input: "Read this out loud", channel: "voice" });  }}
```

Inside a turn, the active channel is available as `this.activeChannel` (a `ChannelContext` with `channelId`, `kind`, and messenger details when relevant). A turn with no `channel` runs without a channel context and applies no channel policy.

## Deliver out of band

`deliverNotice()` sends a message to a channel **without** starting a model turn. Use it for status updates ("your import finished") or to surface an action's [reply attachment](https://developers.cloudflare.com/agents/harnesses/think/actions/#reply-attachments) — it does not run inference, does not enter the turn queue, and is therefore safe to call from inside a tool's `execute`:

* [  JavaScript ](#tab-panel-5797)
* [  TypeScript ](#tab-panel-5798)

JavaScript

```
export class Assistant extends Think {  async notify() {    await this.deliverNotice("Your export is ready to download.");
    await this.deliverNotice("Background research finished.", {      informModel: true, // also record it in the transcript so the next turn knows    });  }}
```

TypeScript

```
export class Assistant extends Think<Env> {  async notify() {    await this.deliverNotice("Your export is ready to download.");
    await this.deliverNotice("Background research finished.", {      informModel: true, // also record it in the transcript so the next turn knows    });  }}
```

TypeScript

```
type DeliverNoticeOptions = {  channel?: string; // defaults to the active turn's channel, else "web"  informModel?: boolean; // also write to the model-visible transcript (default false)  kind?: "final" | "interim" | "notice" | "command"; // wire tag (default "notice")  thread?: string; // required for out-of-turn delivery to a multi-thread messenger};
```

Behavior depends on the target channel:

* **`web`** — the notice is always appended to the transcript (that is its only render path). `informModel` then only controls the phrasing.
* **`messenger`** — the notice is posted to the provider. Out of turn, pass `thread` to target a conversation. With `informModel: true`, it is also written to the transcript.
* **`voice` / `custom`** — out-of-turn delivery throws, because these surfaces have no delivery target yet.

Override `renderAttachment(attachment)` to turn an action reply attachment into a notice; Think calls it at the end of a turn and delivers the rendered text as a trailing `interim` notice. Return `undefined` to skip an attachment type.

## Relationship to messengers

`configureChannels()` wraps `getMessengers()` — it does not replace it. Each `getMessengers()` entry becomes a `kind: "messenger"` channel, and everything in the [Messengers](https://developers.cloudflare.com/agents/harnesses/think/messengers/) guide (Telegram setup, webhook routing, conversation targets, delivery and recovery) continues to apply. A channel id in `configureChannels()` that collides with a `getMessengers()` id is an error. Keep using `getMessengers()` for messenger-only apps; reach for `configureChannels()` when you also want `web`/`voice`/`custom` policy or out-of-band notices.

## Observability

Channel activity is reported on the `channel` observability channel:

* [  JavaScript ](#tab-panel-5799)
* [  TypeScript ](#tab-panel-5800)

JavaScript

```
import { subscribe } from "agents/observability";
const unsubscribe = subscribe("channel", (event) => {  // event.type is one of:  //   "channel:resolved"  — a turn resolved a registered channel  //   "channel:delivered" — a turn's final reply was delivered  //   "notice:delivered"  — deliverNotice() succeeded  //   "notice:failed"     — deliverNotice() threw});
```

TypeScript

```
import { subscribe } from "agents/observability";
const unsubscribe = subscribe("channel", (event) => {  // event.type is one of:  //   "channel:resolved"  — a turn resolved a registered channel  //   "channel:delivered" — a turn's final reply was delivered  //   "notice:delivered"  — deliverNotice() succeeded  //   "notice:failed"     — deliverNotice() threw});
```

## Reference

| Member                        | Description                                                             |
| ----------------------------- | ----------------------------------------------------------------------- |
| configureChannels()           | Return the channel map. Defaults to {} (the implicit web channel only). |
| deliverNotice(text, options?) | Send an out-of-band message to a channel with no model turn.            |
| activeChannel                 | The ChannelContext for the in-flight turn, or undefined.                |
| renderAttachment(attachment)  | Map a reply attachment to channel notice text (or undefined to skip).   |
| defineChannels(channels)      | Identity helper for channel-map type inference.                         |
| messengerChannel(definition)  | Wrap a Chat SDK adapter as a kind: "messenger" channel.                 |

## Related

* [Messengers](https://developers.cloudflare.com/agents/harnesses/think/messengers/) — Chat SDK webhook setup and delivery in depth.
* [Actions](https://developers.cloudflare.com/agents/harnesses/think/actions/) — record reply attachments for `renderAttachment()`.
* [Voice](https://developers.cloudflare.com/agents/communication-channels/voice/) — real-time speech surfaces.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/think/channels/#page","headline":"Channels · Cloudflare Agents docs","description":"Apply per-channel policy, select a channel on a turn, and deliver out-of-band notices across web, messenger, voice, and custom surfaces.","url":"https://developers.cloudflare.com/agents/harnesses/think/channels/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-26","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/harnesses/think/channels/","name":"Channels"}}]}
```

---

---
title: Client tools
description: Browser-side tools, approval flows, auto-continuation, message concurrency, and multi-tab broadcast for Think agents.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Client tools

Think supports tools that execute in the browser. The client sends serializable tool schemas in the chat request body, Think merges them with server tools, and when the LLM calls a client tool, the call is routed to the client for execution.

## Defining client tools

For dynamic client-side tools, pass `tools` to `useAgentChat`. Tools with an `execute` function are registered with the server as client-executed tools:

* [  JavaScript ](#tab-panel-5809)
* [  TypeScript ](#tab-panel-5810)

JavaScript

```
const { messages, sendMessage } = useAgentChat({  agent,  tools: {    getUserTimezone: {      description: "Get the user's timezone from their browser",      parameters: {},      execute: async () => {        return Intl.DateTimeFormat().resolvedOptions().timeZone;      },    },    getClipboard: {      description: "Read text from the user's clipboard",      parameters: {},      execute: async () => {        return navigator.clipboard.readText();      },    },  },});
```

TypeScript

```
const { messages, sendMessage } = useAgentChat({  agent,  tools: {    getUserTimezone: {      description: "Get the user's timezone from their browser",      parameters: {},      execute: async () => {        return Intl.DateTimeFormat().resolvedOptions().timeZone;      },    },    getClipboard: {      description: "Read text from the user's clipboard",      parameters: {},      execute: async () => {        return navigator.clipboard.readText();      },    },  },});
```

Client tools are tools without an `execute` function on the server — they only have a schema. When the LLM produces a tool call for one, Think routes it to the client.

For most apps, prefer defining tools on the server and using `onToolCall` for browser-only execution. The `tools` option is most useful for SDKs or platforms where the browser decides the available tool surface at runtime.

## Client tools over the sub-agent RPC `chat()` path

When a parent agent delegates to a Think sub-agent over RPC with `chat()` (rather than the browser WebSocket), there is no WebSocket to carry `clientTools` or to send tool results back. Pass them through `ChatOptions` instead:

* [  JavaScript ](#tab-panel-5805)
* [  TypeScript ](#tab-panel-5806)

JavaScript

```
await child.chat(message, callback, {  signal,  clientTools: [    {      name: "get_user_timezone",      description: "Get the caller's timezone",      parameters: { type: "object" },    },  ],  onClientToolCall: async ({ toolName, input }) => {    // Run the client tool wherever the parent can — return its output.    return runClientTool(toolName, input);  },});
```

TypeScript

```
await child.chat(message, callback, {  signal,  clientTools: [    {      name: "get_user_timezone",      description: "Get the caller's timezone",      parameters: { type: "object" },    },  ],  onClientToolCall: async ({ toolName, input }) => {    // Run the client tool wherever the parent can — return its output.    return runClientTool(toolName, input);  },});
```

* `clientTools` registers the tool schemas for the turn, exactly like the WebSocket `clientTools` field.
* `onClientToolCall` executes a client-tool call and returns its output. The model can call a client tool, receive the result, and continue — all within the single `chat()` call.

If you omit `onClientToolCall`, the tools are registered but have no result: the model's call is surfaced through the stream callback and the turn ends with a dangling tool call (the RPC stream callback has no inbound result channel of its own). Supply `onClientToolCall` whenever you want the round trip to complete.

### Behavior notes

* **Recovery:** the schemas and `onClientToolCall` executor are per-turn only and are never persisted (the executor is a live RPC reference that dies with the isolate, and unlike the WebSocket path there is no client to replay a `tool-result` after an eviction). If an eviction interrupts the turn while a client-tool call is mid-flight, chat recovery errors the orphaned call (treating it like a server tool) and the model proceeds. To re-run cleanly, the parent re-invokes `chat()` with the `clientTools` and `onClientToolCall` again.
* **Errors:** if `onClientToolCall` throws, the failure is surfaced to the model as a tool error (`output-error`) and the turn continues — it does not crash the turn.
* **Serialization:** the value returned from `onClientToolCall` becomes the tool output, so it must be JSON-serializable (it travels back over RPC and into the model context).
* **No approval gate:** RPC client tools execute immediately through `onClientToolCall`. The WebSocket approval flow (`needsApproval`) does not apply on this path — gate execution inside your executor if you need it.
* **Name precedence:** client tools are merged after server tools, so a client tool that shares a name with a server tool (for example a workspace tool) overrides it for that turn — the same as the WebSocket path.
* **Abort:** aborting the turn via `signal` stops the loop, but an in-flight `onClientToolCall` is not itself cancelled; the turn ends after the current call resolves.

## Approval flow

Handle browser-side tool execution on the client with `onToolCall`:

* [  JavaScript ](#tab-panel-5807)
* [  TypeScript ](#tab-panel-5808)

JavaScript

```
useAgentChat({  agent,  onToolCall: async ({ toolCall, addToolOutput }) => {    if (toolCall.toolName === "read") {      const result = await readFromBrowser(toolCall.input);      addToolOutput({        toolCallId: toolCall.toolCallId,        output: result,      });    }  },});
```

TypeScript

```
useAgentChat({  agent,  onToolCall: async ({ toolCall, addToolOutput }) => {    if (toolCall.toolName === "read") {      const result = await readFromBrowser(toolCall.input);      addToolOutput({        toolCallId: toolCall.toolCallId,        output: result,      });    }  },});
```

## Auto-continuation

After a client tool result is received, Think automatically continues the conversation without a new user message. The continuation turn has `continuation: true` in the `TurnContext`, which you can use in `beforeTurn` to adjust model or tool selection.

When a turn produces several client tool calls at once, Think waits for **all** of their results before starting a single continuation, instead of starting one continuation per result. An immediate resume request that arrives while a continuation is already pending attaches to that pending continuation rather than starting a duplicate, and server-side `needsApproval` continuations resume reliably once the approval is recorded.

## Survive restarts while waiting for a human

A Durable Object can be evicted at any time, including while a turn is paused on an approval prompt or a client-side tool call. Because `Think` enables [chatRecovery](https://developers.cloudflare.com/agents/harnesses/think/recovery/) by default, the SDK treats such a turn as waiting on the human, not stuck. It parks the turn instead of failing it, and the user's eventual approval or tool result resumes the conversation.

For which interactions are exempt from recovery budgets, refer to [Turns waiting on a human are not sealed](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/#turns-waiting-on-a-human-are-not-sealed).

## Message concurrency

The `messageConcurrency` property controls how overlapping user submits behave when a chat turn is already active.

| Strategy                                      | Behavior                                                                                                                            |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| "queue"                                       | Queue every submit and process them in order. Default.                                                                              |
| "latest"                                      | Keep only the latest overlapping submission; superseded submissions still persist their user messages but do not start a model turn |
| "merge"                                       | Queue overlapping submissions, then collapse their trailing user messages into one combined turn before the latest queued turn runs |
| "drop"                                        | Ignore overlapping submits entirely. Messages are not persisted.                                                                    |
| { strategy: "debounce", debounceMs?: number } | Trailing-edge latest with a quiet window (default 750ms).                                                                           |

* [  JavaScript ](#tab-panel-5803)
* [  TypeScript ](#tab-panel-5804)

JavaScript

```
import { Think } from "@cloudflare/think";
export class SearchAgent extends Think {  messageConcurrency = "latest";  getModel() {    /* ... */  }}
```

TypeScript

```
import { Think } from "@cloudflare/think";import type { MessageConcurrency } from "@cloudflare/think";
export class SearchAgent extends Think<Env> {  override messageConcurrency: MessageConcurrency = "latest";  getModel() {    /* ... */  }}
```

## Multi-tab broadcast

Think broadcasts streaming responses to all connected WebSocket clients. When multiple browser tabs are connected to the same agent, all tabs see the streamed response in real time. Tool call states (pending, result, approval) are broadcast to all tabs.

Programmatic `chat()` turns and `clearMessages()` also broadcast message updates to connected `useAgentChat` clients, so browser clients stay in sync without reconnecting.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/think/client-tools/#page","headline":"Client tools · Cloudflare Agents docs","description":"Browser-side tools, approval flows, auto-continuation, message concurrency, and multi-tab broadcast for Think agents.","url":"https://developers.cloudflare.com/agents/harnesses/think/client-tools/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-16","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/harnesses/think/client-tools/","name":"Client tools"}}]}
```

---

---
title: Configuration
description: Configuration overrides, dynamic runtime configuration, Session integration, and package exports for the Think chat agent framework.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Configuration

Think is configured by overriding methods and properties on your `Think` subclass. Most agents only override `getModel()`.

## Configuration overrides

| Method / Property        | Default                        | Description                                                                                                                                                                                                                                                                                                  |
| ------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| getModel()               | throws                         | Return the LanguageModel to use                                                                                                                                                                                                                                                                              |
| getSystemPrompt()        | "You are a helpful assistant." | System prompt (fallback when no context blocks)                                                                                                                                                                                                                                                              |
| getTools()               | {}                             | AI SDK ToolSet for the agentic loop                                                                                                                                                                                                                                                                          |
| getScheduledTasks()      | {}                             | Code-declared recurring prompts or handlers — refer to [Scheduled tasks](https://developers.cloudflare.com/agents/harnesses/think/scheduled-tasks/)                                                                                                                                                          |
| getDefaultTimezone()     | undefined                      | Default timezone for wall-clock scheduled tasks                                                                                                                                                                                                                                                              |
| getMessengers()          | {}                             | Messenger ingress and delivery declarations — refer to [Messengers](https://developers.cloudflare.com/agents/harnesses/think/messengers/)                                                                                                                                                                    |
| maxSteps                 | 10                             | Max tool-call rounds per turn                                                                                                                                                                                                                                                                                |
| sendReasoning            | true                           | Send reasoning chunks to chat clients                                                                                                                                                                                                                                                                        |
| configureSession()       | identity                       | Add context blocks, compaction, search, skills — refer to [Sessions](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/)                                                                                                                                                                   |
| getSkills()              | \[\]                           | Return Agent Skills sources for on-demand skill activation — refer to [Agent Skills](https://developers.cloudflare.com/agents/runtime/execution/agent-skills/)                                                                                                                                               |
| getSkillScriptRunner()   | null                           | Enable the optional run\_skill\_script tool                                                                                                                                                                                                                                                                  |
| workspaceBash            | true                           | Include or configure the default workspace bash tool — refer to [Tools](https://developers.cloudflare.com/agents/harnesses/think/tools/)                                                                                                                                                                     |
| messageConcurrency       | "queue"                        | How overlapping submits behave — refer to [Client tools](https://developers.cloudflare.com/agents/harnesses/think/client-tools/#message-concurrency)                                                                                                                                                         |
| waitForMcpConnections    | false                          | Wait for MCP servers before inference                                                                                                                                                                                                                                                                        |
| chatRecovery             | true                           | Wrap WebSocket, sub-agent, programmatic, and continuation turns in runFiber for durable execution. Set to a configuration object with maxAttempts, stableTimeoutMs, terminalMessage, and onExhausted to tune bounded recovery                                                                                |
| chatStreamStallTimeoutMs | 0 (off)                        | Opt-in inactivity watchdog: abort a turn whose model stream produces no chunk for this long (measures the gap between chunks, including tool execution). With chatRecovery on, a stall routes into bounded recovery                                                                                          |
| contextOverflow          | undefined                      | Opt-in mid-turn context-overflow handling with reactive, maxRetries, and proactive options. Requires classifyChatError plus a session compaction function — refer to [Context-window overflow recovery](https://developers.cloudflare.com/agents/harnesses/think/recovery/#context-window-overflow-recovery) |

For `chatRecovery` and `chatStreamStallTimeoutMs` behavior, refer to [Durable recovery](https://developers.cloudflare.com/agents/harnesses/think/recovery/).

## Dynamic configuration

Think's class generics match `Agent<Env, State, Props>`. Persisted runtime configuration is typed at the `configure<T>()` and `getConfig<T>()` call sites, stored in SQLite, and survives hibernation and restarts.

* [  JavaScript ](#tab-panel-5811)
* [  TypeScript ](#tab-panel-5812)

JavaScript

```
export class MyAgent extends Think {  getModel() {    const tier = this.getConfig()?.modelTier ?? "fast";    const models = {      fast: "@cf/moonshotai/kimi-k2.6",      capable: "@cf/meta/llama-4-scout-17b-16e-instruct",    };    return createWorkersAI({ binding: this.env.AI })(models[tier]);  }}
```

TypeScript

```
type MyConfig = { modelTier: "fast" | "capable"; theme: string };
export class MyAgent extends Think<Env> {  getModel() {    const tier = this.getConfig<MyConfig>()?.modelTier ?? "fast";    const models = {      fast: "@cf/moonshotai/kimi-k2.6",      capable: "@cf/meta/llama-4-scout-17b-16e-instruct",    };    return createWorkersAI({ binding: this.env.AI })(models[tier]);  }}
```

| Method                    | Description                                                   |
| ------------------------- | ------------------------------------------------------------- |
| configure<T>(config: T)   | Persist a typed configuration object                          |
| getConfig<T>(): T \| null | Read the persisted configuration, or null if never configured |

Expose configuration to the client via `@callable`:

* [  JavaScript ](#tab-panel-5813)
* [  TypeScript ](#tab-panel-5814)

JavaScript

```
import { callable } from "agents";
export class MyAgent extends Think {  getModel() {    /* ... */  }
  @callable()  updateConfig(config) {    this.configure(config);  }}
```

TypeScript

```
import { callable } from "agents";
export class MyAgent extends Think<Env> {  getModel() {    /* ... */  }
  @callable()  updateConfig(config: MyConfig) {    this.configure<MyConfig>(config);  }}
```

## Session integration

Think stores conversations in a [Session](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/) — the storage layer that holds your messages and gives the model writable memory. Two concepts come up here: **context blocks** are labelled sections of the system prompt the model can read and update (for example, a `memory` block of facts about the user), and **compaction** summarizes older messages so long conversations stay within the model's context window. Override `configureSession` to add persistent memory, compaction, search, and skills:

* [  JavaScript ](#tab-panel-5815)
* [  TypeScript ](#tab-panel-5816)

JavaScript

```
import { Think, Session } from "@cloudflare/think";
export class MyAgent extends Think {  getModel() {    /* ... */  }
  configureSession(session) {    return session      .withContext("soul", {        provider: { get: async () => "You are a helpful coding assistant." },      })      .withContext("memory", {        description: "Important facts learned during conversation.",        maxTokens: 2000,      })      .withCachedPrompt();  }}
```

TypeScript

```
import { Think, Session } from "@cloudflare/think";
export class MyAgent extends Think<Env> {  getModel() {    /* ... */  }
  configureSession(session: Session) {    return session      .withContext("soul", {        provider: { get: async () => "You are a helpful coding assistant." },      })      .withContext("memory", {        description: "Important facts learned during conversation.",        maxTokens: 2000,      })      .withCachedPrompt();  }}
```

When `configureSession` adds context blocks, Think builds the system prompt from those blocks instead of using `getSystemPrompt()`. Think's `this.messages` getter reads directly from Session's tree-structured storage.

For the full Session API — context blocks, compaction, search, skills, and multi-session support — refer to the [Sessions documentation](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/).

## Package exports

| Export                                | Description                                                  |
| ------------------------------------- | ------------------------------------------------------------ |
| @cloudflare/think                     | Think, Session, Workspace, skills namespace                  |
| @cloudflare/think/messengers          | Messenger contracts, Chat SDK bridge, state agent, delivery  |
| @cloudflare/think/messengers/telegram | Telegram messenger provider and delivery helpers             |
| @cloudflare/think/workflows           | ThinkWorkflow, step.prompt() — Workflow prompts              |
| @cloudflare/think/tools/workspace     | createWorkspaceTools() — for custom storage backends         |
| @cloudflare/think/tools/execute       | createExecuteTool() — sandboxed code execution via Code Mode |
| @cloudflare/think/tools/browser       | createBrowserTools() — Chrome DevTools Protocol tools        |
| @cloudflare/think/tools/extensions    | createExtensionTools() — LLM-driven extension loading        |
| @cloudflare/think/extensions          | ExtensionManager, HostBridgeLoopback — extension runtime     |

## Dependencies

Peer dependencies you provide:

| Package                | Required | Notes                            |
| ---------------------- | -------- | -------------------------------- |
| agents                 | yes      | Cloudflare Agents SDK            |
| ai                     | yes      | AI SDK v6                        |
| zod                    | yes      | Schema validation (v4)           |
| @chat-adapter/telegram | optional | Required for Telegram messengers |

Bundled with `@cloudflare/think`:

| Package              | Notes                                               |
| -------------------- | --------------------------------------------------- |
| @cloudflare/shell    | Workspace filesystem                                |
| @cloudflare/codemode | Code execution for createExecuteTool()              |
| just-bash            | Sandboxed shell for the default workspace bash tool |

The Agent Skills engine and its script runner live in [agents/skills](https://developers.cloudflare.com/agents/runtime/execution/agent-skills/), so skill scripts pull `@cloudflare/worker-bundler` and `just-bash` through `agents`, not Think.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/think/configuration/#page","headline":"Configuration · Cloudflare Agents docs","description":"Configuration overrides, dynamic runtime configuration, Session integration, and package exports for the Think chat agent framework.","url":"https://developers.cloudflare.com/agents/harnesses/think/configuration/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-20","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/harnesses/think/configuration/","name":"Configuration"}}]}
```

---

---
title: Getting started
description: Build a Think chat agent with persistent memory, built-in file tools, custom tools, and streaming, step by step.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Getting started

Build a chat agent with persistent memory, built-in file tools, and streaming — step by step.

If you are brand new to Cloudflare Agents, skim [What are agents?](https://developers.cloudflare.com/agents/concepts/what-are-agents/) first for the core ideas. Otherwise, you can follow along here from scratch.

By the end of this tutorial you will have a Think agent that:

* Streams responses to a React chat UI
* Has persistent memory the model can read and write
* Includes workspace file tools (read, write, edit, find, grep, delete)
* Supports custom server-side tools

## Prerequisites

* Node.js 24+
* A Cloudflare account with Workers AI access
* Familiarity with TypeScript and Cloudflare Workers

## 1\. Create a project

Terminal window

```
mkdir my-think-agent && cd my-think-agentnpm init -y
```

Install dependencies:

Terminal window

```
npm install @cloudflare/think @cloudflare/ai-chat agents ai @cloudflare/shell zod workers-ai-provider react react-domnpm install -D wrangler @cloudflare/vite-plugin @cloudflare/workers-types @vitejs/plugin-react @tailwindcss/vite tailwindcss typescript vite
```

## 2\. Configure wrangler

Create `wrangler.jsonc`:

* [  wrangler.jsonc ](#tab-panel-5817)
* [  wrangler.toml ](#tab-panel-5818)

JSONC

```
{  "name": "my-think-agent",  "compatibility_date": "2026-01-28",  "compatibility_flags": ["nodejs_compat"],  "ai": { "binding": "AI" },  "assets": {    "not_found_handling": "single-page-application",    "run_worker_first": ["/agents/*"]  },  "durable_objects": {    "bindings": [{ "class_name": "MyAgent", "name": "MyAgent" }]  },  "migrations": [{ "new_sqlite_classes": ["MyAgent"], "tag": "v1" }],  "main": "src/server.ts"}
```

TOML

```
name = "my-think-agent"compatibility_date = "2026-01-28"compatibility_flags = [ "nodejs_compat" ]main = "src/server.ts"
[ai]binding = "AI"
[assets]not_found_handling = "single-page-application"run_worker_first = [ "/agents/*" ]
[[durable_objects.bindings]]class_name = "MyAgent"name = "MyAgent"
[[migrations]]new_sqlite_classes = [ "MyAgent" ]tag = "v1"
```

Create `vite.config.ts`:

* [  JavaScript ](#tab-panel-5819)
* [  TypeScript ](#tab-panel-5820)

JavaScript

```
import { cloudflare } from "@cloudflare/vite-plugin";import tailwindcss from "@tailwindcss/vite";import react from "@vitejs/plugin-react";import { defineConfig } from "vite";
export default defineConfig({  plugins: [react(), cloudflare(), tailwindcss()],});
```

TypeScript

```
import { cloudflare } from "@cloudflare/vite-plugin";import tailwindcss from "@tailwindcss/vite";import react from "@vitejs/plugin-react";import { defineConfig } from "vite";
export default defineConfig({  plugins: [react(), cloudflare(), tailwindcss()],});
```

Create `tsconfig.json`:

```
{  "extends": "agents/tsconfig"}
```

## 3\. Define the agent

Create `src/server.ts`:

* [  JavaScript ](#tab-panel-5821)
* [  TypeScript ](#tab-panel-5822)

JavaScript

```
import { Think } from "@cloudflare/think";import { createWorkersAI } from "workers-ai-provider";import { routeAgentRequest } from "agents";
export class MyAgent extends Think {  getModel() {    return createWorkersAI({ binding: this.env.AI })(      "@cf/moonshotai/kimi-k2.6",    );  }
  getSystemPrompt() {    return "You are a helpful assistant with access to a workspace filesystem.";  }}
export default {  async fetch(request, env) {    return (      (await routeAgentRequest(request, env)) ||      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
import { Think } from "@cloudflare/think";import { createWorkersAI } from "workers-ai-provider";import { routeAgentRequest } from "agents";
export class MyAgent extends Think<Env> {  getModel() {    return createWorkersAI({ binding: this.env.AI })(      "@cf/moonshotai/kimi-k2.6",    );  }
  getSystemPrompt() {    return "You are a helpful assistant with access to a workspace filesystem.";  }}
export default {  async fetch(request: Request, env: Env) {    return (      (await routeAgentRequest(request, env)) ||      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

This is a working agent. Think automatically provides:

* WebSocket chat protocol (compatible with `useAgentChat`)
* Message persistence in SQLite
* Resumable streaming (page refresh replays buffered chunks)
* Workspace file tools (read, write, edit, list, find, grep, delete)
* Abort/cancel support
* Error handling with partial message persistence

## 4\. Connect a React client

Create `src/client.tsx`:

* [  JavaScript ](#tab-panel-5829)
* [  TypeScript ](#tab-panel-5830)

JavaScript

```
import { createRoot } from "react-dom/client";import { useAgent } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {  const agent = useAgent({ agent: "MyAgent" });  const { messages, sendMessage, status } = useAgentChat({ agent });
  return (    <div>      <h1>Think Agent</h1>      {messages.map((msg) => (        <div key={msg.id}>          <strong>{msg.role}:</strong>          {msg.parts.map((part, i) =>            part.type === "text" ? <span key={i}>{part.text}</span> : null,          )}        </div>      ))}
      <form        onSubmit={(e) => {          e.preventDefault();          const input = e.currentTarget.elements.namedItem("input");          if (!input.value.trim()) return;          sendMessage({ text: input.value });          input.value = "";        }}      >        <input name="input" placeholder="Send a message..." />        <button type="submit">Send</button>      </form>
      <p>Status: {status}</p>    </div>  );}
const root = document.getElementById("root");if (root) {  createRoot(root).render(<Chat />);}
```

TypeScript

```
import { createRoot } from "react-dom/client";import { useAgent } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {  const agent = useAgent({ agent: "MyAgent" });  const { messages, sendMessage, status } = useAgentChat({ agent });
  return (    <div>      <h1>Think Agent</h1>      {messages.map((msg) => (        <div key={msg.id}>          <strong>{msg.role}:</strong>          {msg.parts.map((part, i) =>            part.type === "text" ? <span key={i}>{part.text}</span> : null,          )}        </div>      ))}
      <form        onSubmit={(e) => {          e.preventDefault();          const input = e.currentTarget.elements.namedItem(            "input",          ) as HTMLInputElement;          if (!input.value.trim()) return;          sendMessage({ text: input.value });          input.value = "";        }}      >        <input name="input" placeholder="Send a message..." />        <button type="submit">Send</button>      </form>
      <p>Status: {status}</p>    </div>  );}
const root = document.getElementById("root");if (root) {  createRoot(root).render(<Chat />);}
```

Create `index.html`:

```
<!doctype html><html lang="en">  <head>    <meta charset="UTF-8" />    <meta name="viewport" content="width=device-width, initial-scale=1.0" />    <title>Think Agent</title>  </head>  <body>    <div id="root"></div>    <script type="module" src="/src/client.tsx"></script>  </body></html>
```

## 5\. Run it

Terminal window

```
npx vite dev
```

Open the browser and send a message. The agent responds with streaming text, and workspace file tools are available to the model automatically.

## 6\. Add persistent memory

Override `configureSession` to give the model writable memory that survives restarts:

* [  JavaScript ](#tab-panel-5823)
* [  TypeScript ](#tab-panel-5824)

JavaScript

```
export class MyAgent extends Think {  getModel() {    return createWorkersAI({ binding: this.env.AI })(      "@cf/moonshotai/kimi-k2.6",    );  }
  configureSession(session) {    return session      .withContext("soul", {        provider: {          get: async () =>            "You are a helpful assistant. Remember important facts about the user.",        },      })      .withContext("memory", {        description: "Important facts about the user and conversation.",        maxTokens: 2000,      })      .withCachedPrompt();  }}
```

TypeScript

```
export class MyAgent extends Think<Env> {  getModel(): LanguageModel {    return createWorkersAI({ binding: this.env.AI })(      "@cf/moonshotai/kimi-k2.6",    );  }
  configureSession(session: Session) {    return session      .withContext("soul", {        provider: {          get: async () =>            "You are a helpful assistant. Remember important facts about the user.",        },      })      .withContext("memory", {        description: "Important facts about the user and conversation.",        maxTokens: 2000,      })      .withCachedPrompt();  }}
```

Now the model sees a `MEMORY` section in its system prompt and gets a `set_context` tool to update it. Facts written to memory persist in SQLite and survive Durable Object hibernation and restarts.

When you use `configureSession`, the system prompt is built from context blocks rather than `getSystemPrompt()`. The `"soul"` block above acts as the system identity — it is read-only and always appears first. The `"memory"` block is writable, and the model proactively updates it when it learns something useful.

Refer to the [Sessions documentation](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/) for context blocks, compaction, search, skills, and multi-session support.

## 7\. Add custom tools

Override `getTools()` to add your own tools alongside the built-in workspace tools:

* [  JavaScript ](#tab-panel-5827)
* [  TypeScript ](#tab-panel-5828)

JavaScript

```
import { tool } from "ai";import { z } from "zod";
export class MyAgent extends Think {  getModel() {    /* ... */  }  configureSession(session) {    /* ... */  }
  getTools() {    return {      getWeather: tool({        description: "Get the current weather for a city",        inputSchema: z.object({          city: z.string().describe("City name"),        }),        execute: async ({ city }) => {          const res = await fetch(            `https://api.weatherapi.com/v1/current.json?key=${this.env.WEATHER_KEY}&q=${city}`,          );          return res.json();        },      }),    };  }}
```

TypeScript

```
import { tool } from "ai";import { z } from "zod";
export class MyAgent extends Think<Env> {  getModel(): LanguageModel {    /* ... */  }  configureSession(session: Session) {    /* ... */  }
  getTools(): ToolSet {    return {      getWeather: tool({        description: "Get the current weather for a city",        inputSchema: z.object({          city: z.string().describe("City name"),        }),        execute: async ({ city }) => {          const res = await fetch(            `https://api.weatherapi.com/v1/current.json?key=${this.env.WEATHER_KEY}&q=${city}`,          );          return res.json();        },      }),    };  }}
```

Think merges tools from multiple sources automatically. On every turn, the model has access to:

1. **Workspace tools** — read, write, edit, list, find, grep, delete, bash (built-in)
2. **Your tools** — from `getTools()`
3. **Extension tools** — from loaded extensions
4. **Session tools** — set\_context, load\_context, search\_context (from `configureSession`)
5. **Skill tools** — activate\_skill, read\_skill\_resource, and optional run\_skill\_script (from `getSkills()`)
6. **MCP tools** — from connected MCP servers (if any)
7. **Client tools** — from the browser (if any)

## 8\. Add lifecycle hooks

Think provides hooks that fire on every turn, regardless of entry path:

* [  JavaScript ](#tab-panel-5825)
* [  TypeScript ](#tab-panel-5826)

JavaScript

```
export class MyAgent extends Think {  getModel() {    /* ... */  }
  beforeTurn(ctx) {    console.log(      `Turn starting: ${Object.keys(ctx.tools).length} tools available`,    );  }
  onChatResponse(result) {    console.log(`Turn ${result.status}: ${result.message.parts.length} parts`);  }}
```

TypeScript

```
import type {  TurnContext,  TurnConfig,  ChatResponseResult,} from "@cloudflare/think";
export class MyAgent extends Think<Env> {  getModel(): LanguageModel {    /* ... */  }
  beforeTurn(ctx: TurnContext): TurnConfig | void {    console.log(      `Turn starting: ${Object.keys(ctx.tools).length} tools available`,    );  }
  onChatResponse(result: ChatResponseResult) {    console.log(`Turn ${result.status}: ${result.message.parts.length} parts`);  }}
```

Refer to [Lifecycle hooks](https://developers.cloudflare.com/agents/harnesses/think/lifecycle-hooks/) for the full reference.

## Next steps

* [Lifecycle hooks](https://developers.cloudflare.com/agents/harnesses/think/lifecycle-hooks/) — control model behavior, switch models per-turn, restrict tools
* [Tools](https://developers.cloudflare.com/agents/harnesses/think/tools/) — workspace tools, code execution, extensions
* [Client tools](https://developers.cloudflare.com/agents/harnesses/think/client-tools/) — browser-side tools, approval flows, concurrency
* [Sub-agent RPC and programmatic turns](https://developers.cloudflare.com/agents/harnesses/think/sub-agents/) — RPC streaming, scheduled turns, recovery
* [Sessions](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/) — context blocks, compaction, search, multi-session

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/think/getting-started/#page","headline":"Getting started · Cloudflare Agents docs","description":"Build a Think chat agent with persistent memory, built-in file tools, custom tools, and streaming, step by step.","url":"https://developers.cloudflare.com/agents/harnesses/think/getting-started/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/harnesses/think/getting-started/","name":"Getting started"}}]}
```

---

---
title: Lifecycle hooks
description: Hooks at each stage of a Think chat turn — beforeTurn, beforeStep, beforeToolCall, afterToolCall, onStepFinish, onChunk, onChatResponse, and onChatError.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Lifecycle hooks

Think owns the `streamText` call and provides hooks at each stage of the chat turn. Hooks fire on every turn regardless of entry path — WebSocket chat, sub-agent `chat()`, `saveMessages()`, durable `submitMessages()` execution, `continueLastTurn()`, and auto-continuation after tool results.

## Hook summary

| Hook                           | When it fires                                             | Return                          | Async |
| ------------------------------ | --------------------------------------------------------- | ------------------------------- | ----- |
| configureSession(session)      | Once during onStart                                       | Session                         | yes   |
| beforeTurn(ctx)                | Before streamText                                         | TurnConfig or void              | yes   |
| beforeStep(ctx)                | Before each model step                                    | StepConfig or void              | yes   |
| beforeToolCall(ctx)            | Before a server-side tool executes                        | ToolCallDecision or void        | yes   |
| afterToolCall(ctx)             | After a tool outcome is known                             | void                            | yes   |
| onStepFinish(ctx)              | After each step completes                                 | void                            | yes   |
| onChunk(ctx)                   | Per streaming chunk                                       | void                            | yes   |
| onChatResponse(result)         | After turn completes and message is persisted             | void                            | yes   |
| onChatError(error, ctx?)       | On error during a turn                                    | error to propagate              | no    |
| classifyChatError(error, ctx?) | On a turn error, when contextOverflow.reactive is enabled | ChatErrorClassification or void | no    |

## Execution order

For a turn with two tool calls:

flowchart TD
    cfg["configureSession() — once at startup, not per-turn"] --> bt["beforeTurn() — inspect context, override model/tools/prompt"]
    bt --> bs

    subgraph loop ["streamText (repeats per step)"]
        bs["beforeStep()"] --> chunk["onChunk() — per streaming chunk"]
        chunk --> btc["beforeToolCall()"]
        btc --> exec["tool executes"]
        exec --> atc["afterToolCall()"]
        atc --> sf["onStepFinish()"]
        sf -->|"more steps"| bs
    end

    sf -->|"turn complete"| ocr["onChatResponse() — message persisted, turn lock released"]

## beforeTurn

Called before `streamText`. Receives the fully assembled context — system prompt, converted messages, merged tools, and model. Return a `TurnConfig` to override any part, or void to accept defaults.

TypeScript

```
beforeTurn(ctx: TurnContext): TurnConfig | void | Promise<TurnConfig | void>
```

### TurnContext

| Field        | Type                    | Description                                                                  |
| ------------ | ----------------------- | ---------------------------------------------------------------------------- |
| system       | string                  | Assembled system prompt (from context blocks or getSystemPrompt())           |
| messages     | ModelMessage\[\]        | Assembled model messages (truncated, pruned)                                 |
| tools        | ToolSet                 | Merged tool set (workspace + getTools + session + extensions + MCP + client) |
| model        | LanguageModel           | The model from getModel()                                                    |
| continuation | boolean                 | Whether this is a continuation turn (auto-continue after tool result)        |
| body         | Record<string, unknown> | Custom body fields from the client request                                   |

### TurnConfig

All fields are optional. Return only what you want to change.

| Field                    | Type                                           | Description                                                                                                                                                                                                                              |
| ------------------------ | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| model                    | LanguageModel                                  | Override the model for this turn                                                                                                                                                                                                         |
| system                   | string                                         | Override the system prompt                                                                                                                                                                                                               |
| messages                 | ModelMessage\[\]                               | Override the assembled messages                                                                                                                                                                                                          |
| tools                    | ToolSet                                        | Extra tools to merge (additive)                                                                                                                                                                                                          |
| activeTools              | string\[\]                                     | Limit which tools the model can call                                                                                                                                                                                                     |
| toolChoice               | ToolChoice                                     | Force a specific tool call                                                                                                                                                                                                               |
| maxSteps                 | number                                         | Override maxSteps for this turn                                                                                                                                                                                                          |
| sendReasoning            | boolean                                        | Send reasoning chunks for this turn                                                                                                                                                                                                      |
| chatStreamStallTimeoutMs | number                                         | Override the stream-stall watchdog for this turn (0 disables it); auto-resets after the turn. Useful for a turn with a known-slow tool — refer to [Durable recovery](https://developers.cloudflare.com/agents/harnesses/think/recovery/) |
| output                   | Output                                         | Request structured output for this turn                                                                                                                                                                                                  |
| providerOptions          | Record<string, unknown>                        | Provider-specific options                                                                                                                                                                                                                |
| experimental\_telemetry  | object                                         | AI SDK telemetry settings for this turn                                                                                                                                                                                                  |
| experimental\_transform  | StreamTextTransform \| StreamTextTransform\[\] | AI SDK stream transform(s) for this turn — inspect or rewrite stream parts (for example, emit source parts derived from tool results). Applied in order.                                                                                 |

### Examples

Switch to a cheaper model for continuation turns:

TypeScript

```
beforeTurn(ctx: TurnContext) {  if (ctx.continuation) {    return { model: this.cheapModel };  }}
```

Restrict which tools the model can call:

TypeScript

```
beforeTurn(ctx: TurnContext) {  return { activeTools: ["read", "write", "getWeather"] };}
```

Add per-turn context from the client body:

TypeScript

```
beforeTurn(ctx: TurnContext) {  if (ctx.body?.selectedFile) {    return {      system: ctx.system + `\n\nUser is editing: ${ctx.body.selectedFile}`,    };  }}
```

Hide reasoning for internal continuation turns:

TypeScript

```
beforeTurn(ctx: TurnContext) {  if (ctx.continuation) {    return { sendReasoning: false };  }}
```

Force structured output for a turn:

TypeScript

```
import { Output } from "ai";import { z } from "zod";
const ResultSchema = z.object({ severity: z.enum(["low", "high"]) });
beforeTurn(ctx: TurnContext) {  if (ctx.body?.mode === "structured-answer") {    return {      output: Output.object({ schema: ResultSchema }),      activeTools: [],    };  }}
```

`output` is a turn-level setting only. The AI SDK's `prepareStep` does not accept an `output` override, so `beforeStep` cannot toggle structured output on a single step.

## beforeStep

Called before each AI SDK step in the agentic loop. Think forwards this hook to `streamText` as `prepareStep`, so it receives the AI SDK's full prepare-step context and can return per-step overrides. Use `beforeTurn` for turn-wide assembly and `beforeStep` when the decision depends on the step number or previous step results.

TypeScript

```
beforeStep(ctx: PrepareStepContext): StepConfig | void {  if (ctx.stepNumber > 0) {    return { activeTools: [] };  }}
```

## beforeToolCall

Called before a server-side tool's `execute` function runs. Think wraps each server-side tool so the hook can allow, modify, block, or substitute the call before the model receives the tool result.

TypeScript

```
beforeToolCall(ctx: ToolCallContext): ToolCallDecision | void {  if (ctx.toolName === "delete" && this.isReadOnlyMode) {    return { action: "block", reason: "delete is disabled in read-only mode" };  }
  if (ctx.toolName === "weather") {    const cached = this.weatherCache.get(JSON.stringify(ctx.input));    if (cached) return { action: "substitute", output: cached };  }}
```

| Field       | Type                     | Description                                |
| ----------- | ------------------------ | ------------------------------------------ |
| toolName    | string                   | Name of the tool being called              |
| input       | unknown                  | Input the model provided                   |
| toolCallId  | string                   | ID for this tool call                      |
| messages    | ModelMessage\[\]         | Messages visible at tool execution time    |
| abortSignal | AbortSignal \| undefined | Signal that aborts if the turn is canceled |

Return a `ToolCallDecision` to control execution:

| Decision                         | Behavior                                                    |
| -------------------------------- | ----------------------------------------------------------- |
| void or { action: "allow" }      | Run the original tool with the original input               |
| { action: "allow", input }       | Run the original tool with modified input                   |
| { action: "block", reason }      | Skip the original tool and return reason as the tool result |
| { action: "substitute", output } | Skip the original tool and return output as the tool result |

If a wrapped tool returns an `AsyncIterable` for preliminary tool results, Think collapses the iterable to its final yielded value after `beforeToolCall` runs. If you need true preliminary streaming from that tool, avoid intercepting it with `beforeToolCall`.

## afterToolCall

Called after a tool outcome is known. This includes real executions, blocked calls, substituted calls, and thrown tool errors.

TypeScript

```
afterToolCall(ctx: ToolCallResultContext) {  if (!ctx.success) return;
  this.env.ANALYTICS.writeDataPoint({    blobs: [ctx.toolName],    doubles: [JSON.stringify(ctx.output).length],  });}
```

| Field      | Type             | Description                                          |
| ---------- | ---------------- | ---------------------------------------------------- |
| toolName   | string           | Name of the tool that was called                     |
| input      | unknown          | Input the model provided                             |
| toolCallId | string           | ID for this tool call                                |
| messages   | ModelMessage\[\] | Messages visible at tool execution time              |
| durationMs | number           | Tool execution duration in milliseconds              |
| success    | boolean          | Whether the model received a successful tool outcome |
| output     | unknown          | Present when success is true                         |
| error      | unknown          | Present when success is false                        |

For blocked and substituted tool calls, `success` is `true` because the model receives a valid tool result. Only thrown errors from the original tool execution surface as `success: false`.

## onStepFinish

Called after each step completes in the agentic loop. `StepContext` is the AI SDK's step-finish event, so it includes the full step record: generated text, reasoning, files, sources, typed tool calls and results, usage, warnings, request and response metadata, and provider metadata.

TypeScript

```
onStepFinish(ctx: StepContext) {  console.log(    `Step ${ctx.stepNumber} (${ctx.finishReason}): ` +      `${ctx.usage.inputTokens}in/${ctx.usage.outputTokens}out`,  );}
```

| Field            | Description                                       |
| ---------------- | ------------------------------------------------- |
| stepNumber       | Zero-based index of the step                      |
| text             | Text generated in this step                       |
| reasoning        | Reasoning parts emitted by the model              |
| files            | Files generated during the step                   |
| sources          | Citations or sources used by the model            |
| toolCalls        | Typed tool calls made in this step                |
| toolResults      | Typed tool results received in this step          |
| finishReason     | Why the step ended                                |
| usage            | Token usage, including cache and reasoning tokens |
| providerMetadata | Provider-specific metadata                        |

## onChunk

Called for each streaming chunk. High-frequency — fires per token. Use for streaming analytics, progress indicators, or token counting. Observational only.

## onChatResponse

Called after a chat turn produces and persists an assistant message. The turn lock is released before this hook runs, so it is safe to call `saveMessages` or other methods from inside.

Fires for all turn paths that persist an assistant message: WebSocket, sub-agent RPC, `saveMessages`, and auto-continuation. If a turn fails before producing any assistant parts, `onChatError` handles the error instead.

TypeScript

```
onChatResponse(result: ChatResponseResult) {  if (result.status === "completed") {    console.log(`Turn ${result.requestId}: ${result.message.parts.length} parts`);  }}
```

| Field        | Type                   | Description                            |                    |
| ------------ | ---------------------- | -------------------------------------- | ------------------ |
| message      | UIMessage              | The persisted assistant message        |                    |
| requestId    | string                 | Unique ID for this turn                |                    |
| continuation | boolean                | Whether this was a continuation turn   |                    |
| status       | "completed" \| "error" | "aborted"                              | How the turn ended |
| error        | string?                | Error message (when status is "error") |                    |

## onChatError

Called when an error occurs during a chat turn. Return the error to propagate it, or return a different error. The optional context describes where the failure happened and whether user messages were already persisted. The partial assistant message (if any) is persisted before this hook fires.

TypeScript

```
onChatError(error: unknown, ctx?: ChatErrorContext): unknown
```

`ChatErrorContext` includes:

| Field             | Type                                 | Description                                                                                                                                                                   |          |            |              |               |
| ----------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------- | ------------ | ------------- |
| requestId         | string \| undefined                  | Chat request ID, when available                                                                                                                                               |          |            |              |               |
| stage             | "parse" \| "persist"                 | "turn"                                                                                                                                                                        | "stream" | "recovery" | "transcript" | Failure stage |
| messagesPersisted | boolean                              | Whether incoming user messages were already stored                                                                                                                            |          |            |              |               |
| classification    | ChatErrorClassification \| undefined | Set to "context\_overflow" on the terminal onChatError when a context overflow could not be recovered (refer to [classifyChatError](#classifychaterror)); undefined otherwise |          |            |              |               |

Think also emits `chat:request:failed` on the `agents:chat` observability channel with the same stage and persistence information.

TypeScript

```
onChatError(error: unknown, ctx?: ChatErrorContext) {  console.error("Chat turn failed:", ctx?.stage, error);  if (ctx?.classification === "context_overflow") {    return new Error("This conversation is too long to continue. Please start a new one.");  }  return new Error("Something went wrong. Please try again.");}
```

## classifyChatError

Called when an error occurs during a turn, **before** `onChatError`. Maps a raw provider error into a provider-agnostic category so Think can react without baking provider-specific strings into the framework — the same split as the `tokenCounter` you pass to `compactAfter()`. The app owns the mapping because it knows which provider and model it talks to.

TypeScript

```
classifyChatError(error: unknown, ctx?: ChatErrorContext): ChatErrorClassification | void
```

`ChatErrorClassification` is `"context_overflow" | "rate_limit" | "transient" | "fatal" | "unknown"`. Today this hook drives only context-overflow recovery. Think calls it when a turn errors and `contextOverflow.reactive` is enabled. If reactive is off, it is not called.

Returning `"context_overflow"` runs the compact-and-retry backstop (refer to [Context-window overflow recovery](https://developers.cloudflare.com/agents/harnesses/think/recovery/#context-window-overflow-recovery)). If recovery cannot save the turn, that classification is surfaced on the terminal `onChatError` call through `ChatErrorContext.classification`.

The other categories are reserved for future use. Returning one today is a no-op and is not forwarded to `onChatError`. Returning `void` (the default) keeps the existing terminal behavior.

The argument may be an `Error`, an AI SDK `APICallError` (with `statusCode`/`responseBody`), or — for in-stream provider errors that surface as a stream error part rather than a throw — the error message string. Narrow accordingly. Provider context-overflow errors arrive as in-stream error parts, so this hook receives them in string form, not as a thrown exception.

The second argument is a [ChatErrorContext](#onchaterror). During overflow recovery it is `{ stage: "stream", requestId }`, so a classifier can correlate the error with the in-flight turn — for example, to call `cancelChat(requestId)` and bail out of recovery.

### Example

For the common case, assign the bundled `defaultContextOverflowClassifier`, which matches the context-overflow errors of Anthropic, OpenAI, Google, Bedrock, and others:

* [  JavaScript ](#tab-panel-5831)
* [  TypeScript ](#tab-panel-5832)

JavaScript

```
import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";
export class MyAgent extends Think {  classifyChatError = defaultContextOverflowClassifier;}
```

TypeScript

```
import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";
export class MyAgent extends Think<Env> {  override classifyChatError = defaultContextOverflowClassifier;}
```

Or write your own, optionally delegating to the bundled classifier:

* [  JavaScript ](#tab-panel-5833)
* [  TypeScript ](#tab-panel-5834)

JavaScript

```
import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";
export class MyAgent extends Think {  classifyChatError(error) {    if (error instanceof Error && /rate.?limit/i.test(error.message)) {      return "rate_limit";    }    return defaultContextOverflowClassifier(error);  }}
```

TypeScript

```
import type { ChatErrorClassification } from "@cloudflare/think";import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";
export class MyAgent extends Think<Env> {  override classifyChatError(error: unknown): ChatErrorClassification | void {    if (error instanceof Error && /rate.?limit/i.test(error.message)) {      return "rate_limit";    }    return defaultContextOverflowClassifier(error);  }}
```

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/think/lifecycle-hooks/#page","headline":"Lifecycle hooks · Cloudflare Agents docs","description":"Hooks at each stage of a Think chat turn — beforeTurn, beforeStep, beforeToolCall, afterToolCall, onStepFinish, onChunk, onChatResponse, and onChatError.","url":"https://developers.cloudflare.com/agents/harnesses/think/lifecycle-hooks/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-16","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/harnesses/think/lifecycle-hooks/","name":"Lifecycle hooks"}}]}
```

---

---
title: Messengers
description: Receive and reply to Chat SDK messenger webhooks directly from a Think agent, including Telegram setup, routing, conversation targets, and recovery.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Messengers

Use messengers when a Think agent should receive and reply to Chat SDK webhooks directly. Think owns the webhook route, durable reply fiber, conversation routing, and streamed delivery back to the provider.

## Install

Install the Think package and the provider adapter you use:

Terminal window

```
npm install @cloudflare/think agents ai @chat-adapter/telegram
```

Provider adapters are exported from provider-specific subpaths so unused adapters are not bundled into your Worker.

## Telegram

* [  JavaScript ](#tab-panel-5845)
* [  TypeScript ](#tab-panel-5846)

JavaScript

```
import { Think } from "@cloudflare/think";import {  defineMessengers,  ThinkMessengerStateAgent,} from "@cloudflare/think/messengers";import telegramMessenger from "@cloudflare/think/messengers/telegram";
export { ThinkMessengerStateAgent };
export class SupportAgent extends Think {  getMessengers() {    return defineMessengers({      telegram: telegramMessenger({        token: this.env.TELEGRAM_BOT_TOKEN,        userName: "support_bot",        secretToken: this.env.TELEGRAM_WEBHOOK_SECRET_TOKEN,      }),    });  }}
```

TypeScript

```
import { Think } from "@cloudflare/think";import {  defineMessengers,  ThinkMessengerStateAgent,} from "@cloudflare/think/messengers";import telegramMessenger from "@cloudflare/think/messengers/telegram";
export { ThinkMessengerStateAgent };
export class SupportAgent extends Think<Env> {  getMessengers() {    return defineMessengers({      telegram: telegramMessenger({        token: this.env.TELEGRAM_BOT_TOKEN,        userName: "support_bot",        secretToken: this.env.TELEGRAM_WEBHOOK_SECRET_TOKEN,      }),    });  }}
```

With the default `telegram` key, register the Telegram webhook at:

```
https://<your-worker>/messengers/telegram/webhook
```

`telegramMessenger()` requires `secretToken` in webhook mode unless you pass a custom `verifyWebhook` function or explicitly opt out with `verifyWebhook: false`.

If one Think agent owns multiple Telegram bots, give each provider a distinct Chat SDK adapter name:

* [  JavaScript ](#tab-panel-5841)
* [  TypeScript ](#tab-panel-5842)

JavaScript

```
defineMessengers({  support: telegramMessenger({    adapterName: "support-telegram",    token: this.env.SUPPORT_TELEGRAM_BOT_TOKEN,    userName: "support_bot",    secretToken: this.env.SUPPORT_TELEGRAM_WEBHOOK_SECRET_TOKEN,  }),  sales: telegramMessenger({    adapterName: "sales-telegram",    token: this.env.SALES_TELEGRAM_BOT_TOKEN,    userName: "sales_bot",    secretToken: this.env.SALES_TELEGRAM_WEBHOOK_SECRET_TOKEN,  }),});
```

TypeScript

```
defineMessengers({  support: telegramMessenger({    adapterName: "support-telegram",    token: this.env.SUPPORT_TELEGRAM_BOT_TOKEN,    userName: "support_bot",    secretToken: this.env.SUPPORT_TELEGRAM_WEBHOOK_SECRET_TOKEN,  }),  sales: telegramMessenger({    adapterName: "sales-telegram",    token: this.env.SALES_TELEGRAM_BOT_TOKEN,    userName: "sales_bot",    secretToken: this.env.SALES_TELEGRAM_WEBHOOK_SECRET_TOKEN,  }),});
```

Duplicate adapter names fail during startup so providers cannot overwrite each other in the shared Chat SDK runtime.

## Routing

The root Think agent handles messenger webhook routes after framework sub-agent routing and Think internal routes, but before user-defined `onRequest` fallback. Messenger routes are root-only. Defining `getMessengers()` on a sub-agent class does not create webhook routes for that sub-agent.

By default, Think replies to direct messages and mentions. New mentions subscribe the Chat SDK thread so later mentions in the same thread are still observed, but ordinary subscribed-thread messages and button actions are ignored unless you opt in:

* [  JavaScript ](#tab-panel-5835)
* [  TypeScript ](#tab-panel-5836)

JavaScript

```
telegramMessenger({  token: this.env.TELEGRAM_BOT_TOKEN,  userName: "support_bot",  secretToken: this.env.TELEGRAM_WEBHOOK_SECRET_TOKEN,  respondTo: ["direct-message", "mention", "subscribed-thread", "action"],});
```

TypeScript

```
telegramMessenger({  token: this.env.TELEGRAM_BOT_TOKEN,  userName: "support_bot",  secretToken: this.env.TELEGRAM_WEBHOOK_SECRET_TOKEN,  respondTo: ["direct-message", "mention", "subscribed-thread", "action"],});
```

Action events are converted into Think user messages with the action id, value, source message id, and initiating user. Use `getMessengerContext()?.action` inside hooks or tools when you need provider-specific action details. Actions are opt-in so interactive cards do not accidentally trigger model turns.

## Conversation targets

The default conversation mode is one Think sub-agent per Chat SDK thread. This keeps group chats, direct messages, and channels from sharing memory accidentally.

Use the root agent as the conversation when all messenger traffic should share one Think session:

* [  JavaScript ](#tab-panel-5837)
* [  TypeScript ](#tab-panel-5838)

JavaScript

```
telegramMessenger({  token: this.env.TELEGRAM_BOT_TOKEN,  userName: "support_bot",  secretToken: this.env.TELEGRAM_WEBHOOK_SECRET_TOKEN,  conversation: "self",});
```

TypeScript

```
telegramMessenger({  token: this.env.TELEGRAM_BOT_TOKEN,  userName: "support_bot",  secretToken: this.env.TELEGRAM_WEBHOOK_SECRET_TOKEN,  conversation: "self",});
```

Use a resolver when routing depends on tenant, channel, thread, or user:

* [  JavaScript ](#tab-panel-5843)
* [  TypeScript ](#tab-panel-5844)

JavaScript

```
telegramMessenger({  token: this.env.TELEGRAM_BOT_TOKEN,  userName: "support_bot",  secretToken: this.env.TELEGRAM_WEBHOOK_SECRET_TOKEN,  conversation(event) {    return {      target: "subagent",      name: `tenant:${event.thread.channelId ?? event.thread.id}`,    };  },});
```

TypeScript

```
telegramMessenger({  token: this.env.TELEGRAM_BOT_TOKEN,  userName: "support_bot",  secretToken: this.env.TELEGRAM_WEBHOOK_SECRET_TOKEN,  conversation(event) {    return {      target: "subagent",      name: `tenant:${event.thread.channelId ?? event.thread.id}`,    };  },});
```

## State

Messenger state is backed by `agents/chat-sdk`. Export `ThinkMessengerStateAgent` from the Worker module so sub-agent routing can resolve it. Production applications do not need a separate Durable Object binding or migration for this facet-only state class. Test harnesses may still need explicit bindings.

## Delivery and recovery

Think replies with the streamed `chat()` path. The root agent starts an idempotent managed fiber, resolves the conversation target, calls `target.chat(message, callback)`, and lets the provider delivery policy post or edit visible messages.

Recovery snapshots store only serializable event and Chat SDK thread data. If a restart happens before streaming starts, Think can replay the answer. If a restart happens after streaming starts, Think posts the configured interruption message instead of risking a duplicate partial answer.

Delivery errors use a generic user-facing message by default so internal exception details are not posted into external chats. Override `delivery.errorResponseText` when you want a custom safe message.

## Messenger context

During a messenger turn, `getMessengerContext()` returns provider, thread, author, message, capabilities, and attachment metadata for the initiating event. Use it from prompts, tools, or hooks that need channel-specific behavior.

* [  JavaScript ](#tab-panel-5839)
* [  TypeScript ](#tab-panel-5840)

JavaScript

```
const messenger = this.getMessengerContext();if (messenger?.thread.isDirectMessage === false) {  // Adjust behavior for group chats.}
```

TypeScript

```
const messenger = this.getMessengerContext();if (messenger?.thread.isDirectMessage === false) {  // Adjust behavior for group chats.}
```

## Custom Chat SDK adapters

Use `chatSdkMessenger()` for providers that do not have a Think helper yet:

* [  JavaScript ](#tab-panel-5847)
* [  TypeScript ](#tab-panel-5848)

JavaScript

```
chatSdkMessenger({  adapter,  provider: "custom",  userName: "custom_bot",  verifyWebhook(request) {    return request.headers.get("x-custom-signature") === expectedSignature;  },});
```

TypeScript

```
chatSdkMessenger({  adapter,  provider: "custom",  userName: "custom_bot",  verifyWebhook(request) {    return request.headers.get("x-custom-signature") === expectedSignature;  },});
```

Every custom messenger must provide `verifyWebhook` or explicitly use `verifyWebhook: false`.

## Advanced manual ingress

The `examples/think-chat-sdk` example demonstrates the Think-native `getMessengers()` path with a small Vite dashboard that inspects the root Think conversation over the Agent WebSocket.

The `examples/chat-sdk-messenger` example demonstrates a larger manual ingress agent with an admin dashboard, menu handling, and application-owned reply fibers. Use `getMessengers()` for the simple Think-native path. Use the example when you need to own the Chat SDK runtime and control-plane UI yourself. Refer to [Chat SDK state](https://developers.cloudflare.com/agents/runtime/communication/chat-sdk/) for the underlying state adapter.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/think/messengers/#page","headline":"Messengers · Cloudflare Agents docs","description":"Receive and reply to Chat SDK messenger webhooks directly from a Think agent, including Telegram setup, routing, conversation targets, and recovery.","url":"https://developers.cloudflare.com/agents/harnesses/think/messengers/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-09","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/harnesses/think/messengers/","name":"Messengers"}}]}
```

---

---
title: Programmatic submissions
description: Durably accept a Think turn with submitMessages() for webhooks and RPC callers, with idempotent retry, status inspection, and cancellation.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Programmatic submissions

Durably accept a Think turn and return before inference runs. Use `submitMessages()` for webhook handlers, RPC callers, and parent Workers that need a fast acknowledgement, safe retry, and later status inspection.

Declarative [scheduled prompt tasks](https://developers.cloudflare.com/agents/harnesses/think/scheduled-tasks/) use the same durable submission path under the hood. Use `getScheduledTasks()` when the trigger is recurring and code-declared; use `submitMessages()` directly when an external caller or webhook creates one-off work. To wait for the response inline, use [saveMessages()](https://developers.cloudflare.com/agents/harnesses/think/sub-agents/#savemessages) instead.

## submitMessages

TypeScript

```
async submitMessages(  messages: UIMessage[],  options?: {    submissionId?: string;    idempotencyKey?: string;    metadata?: Record<string, unknown>;  },): Promise<SubmitMessagesResult>
```

`submitMessages()` accepts serializable `UIMessage[]` values. It does not accept the function form supported by `saveMessages((messages) => ...)`, because durable submissions persist work before execution and cannot store closures. The array must contain at least one message.

* [  JavaScript ](#tab-panel-5851)
* [  TypeScript ](#tab-panel-5852)

JavaScript

```
const submission = await this.submitMessages(  [    {      id: crypto.randomUUID(),      role: "user",      parts: [{ type: "text", text: "Process webhook event 123" }],    },  ],  { idempotencyKey: "webhook-event-123" },);
return Response.json({  submissionId: submission.submissionId,  status: submission.status,  accepted: submission.accepted,});
```

TypeScript

```
const submission = await this.submitMessages(  [    {      id: crypto.randomUUID(),      role: "user",      parts: [{ type: "text", text: "Process webhook event 123" }],    },  ],  { idempotencyKey: "webhook-event-123" },);
return Response.json({  submissionId: submission.submissionId,  status: submission.status,  accepted: submission.accepted,});
```

## Submission statuses

| Status    | Meaning                                        |
| --------- | ---------------------------------------------- |
| pending   | Accepted and waiting for its turn              |
| running   | Claimed by the agent and executing             |
| completed | The Think turn completed successfully          |
| aborted   | The submission was cancelled                   |
| skipped   | Turn state was reset before the submission ran |
| error     | Execution failed or recovery was unsafe        |

## Idempotent retries

Pass an `idempotencyKey` from your external system. Retrying with the same key returns the existing submission with `accepted: false` instead of inserting duplicate messages:

* [  JavaScript ](#tab-panel-5849)
* [  TypeScript ](#tab-panel-5850)

JavaScript

```
const first = await this.submitMessages(messages, {  idempotencyKey: payload.id,});
const retry = await this.submitMessages(messages, {  idempotencyKey: payload.id,});
console.log(first.submissionId === retry.submissionId); // trueconsole.log(retry.accepted); // false
```

TypeScript

```
const first = await this.submitMessages(messages, {  idempotencyKey: payload.id,});
const retry = await this.submitMessages(messages, {  idempotencyKey: payload.id,});
console.log(first.submissionId === retry.submissionId); // trueconsole.log(retry.accepted); // false
```

If you pass both `submissionId` and `idempotencyKey`, they must identify the same submission. If they point at different existing submissions, `submitMessages()` throws.

## Inspect, list, cancel, and delete

Use the submission APIs to inspect active work, cancel a durable submission, and clean up terminal records:

* [  JavaScript ](#tab-panel-5853)
* [  TypeScript ](#tab-panel-5854)

JavaScript

```
const current = await this.inspectSubmission(submission.submissionId);
const active = await this.listSubmissions({  status: ["pending", "running"],});
await this.cancelSubmission(submission.submissionId, "No longer needed");
await this.deleteSubmissions({  status: ["completed", "error", "aborted"],  completedBefore: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),});
```

TypeScript

```
const current = await this.inspectSubmission(submission.submissionId);
const active = await this.listSubmissions({  status: ["pending", "running"],});
await this.cancelSubmission(submission.submissionId, "No longer needed");
await this.deleteSubmissions({  status: ["completed", "error", "aborted"],  completedBefore: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),});
```

Use `cancelSubmission(submissionId)` for durable cancellation across Worker and Durable Object RPC boundaries. Use `AbortSignal` with `saveMessages()` or `continueLastTurn()` only when the caller creates the signal inside the Durable Object that runs the turn.

## Session behavior

Think stores accepted submissions in a submission ledger first. It appends submitted messages to the conversation Session only when the submission starts executing. Later accepted submissions are not visible to the model until their own turn starts, which preserves first-in, first-out turn semantics.

If you cancel a submission before its messages have been applied, including one that has been claimed but is still waiting for its turn, those messages are not persisted to the conversation.

If the chat is cleared or turn state is reset before a pending submission runs, the submission is marked `skipped`.

## Compared with Workflows

Use Workflows for multi-step orchestration, retries per step, long waits, external events, human approvals, or pipelines that may trigger Think as one part of a larger process. Refer to [Think Workflows](https://developers.cloudflare.com/agents/harnesses/think/workflows/).

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/think/programmatic-submissions/#page","headline":"Programmatic submissions · Cloudflare Agents docs","description":"Durably accept a Think turn with submitMessages() for webhooks and RPC callers, with idempotent retry, status inspection, and cancellation.","url":"https://developers.cloudflare.com/agents/harnesses/think/programmatic-submissions/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-16","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/harnesses/think/programmatic-submissions/","name":"Programmatic submissions"}}]}
```

---

---
title: Durable recovery
description: Bounded chat recovery, the stream-stall watchdog, repairing interrupted tool calls, and stability detection for Think agents.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Durable recovery

Think wraps chat turns in recoverable [fibers](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/) by default (`chatRecovery = true`). If the Durable Object is evicted mid-stream, Think reconstructs any buffered chunks, persists partial output, and schedules either a continuation of the assistant turn or a retry of the unanswered user turn.

Note

This is on by default and works without configuration — most apps never touch this page. Read it when you want provider-specific recovery, a stall watchdog, or to tune the terminal experience after recovery gives up.

When `chatRecovery` is `true`, WebSocket turns, sub-agent `chat()` turns, durable `submitMessages()` executions, auto-continuations, `saveMessages()`, and `continueLastTurn()` are wrapped in `runFiber`.

## Bounded recovery

A stream-stall watchdog abort (`chatStreamStallTimeoutMs`) is treated as just another interruption: when `chatRecovery` is on, a stall routes into this same bounded path — the settled partial is preserved and a continuation is scheduled — so a transient hang recovers automatically. A persistently hanging provider exhausts the budget and terminalizes through the **same** exhaustion handling as a deploy or eviction interruption: `onExhausted` fires, the `chat:recovery:exhausted` event is emitted, and the configured `terminalMessage` is shown (not a raw stall error).

Configure bounded recovery by setting `chatRecovery` to an object:

* [  JavaScript ](#tab-panel-5855)
* [  TypeScript ](#tab-panel-5856)

JavaScript

```
export class MyAgent extends Think {  chatRecovery = {    maxAttempts: 6,    stableTimeoutMs: 10_000,    terminalMessage: "The assistant was interrupted and could not recover.",    async onExhausted(ctx) {      console.warn("Chat recovery exhausted", ctx.incidentId);    },  };
  getModel() {    /* ... */  }}
```

TypeScript

```
export class MyAgent extends Think<Env> {  override chatRecovery = {    maxAttempts: 6,    stableTimeoutMs: 10_000,    terminalMessage: "The assistant was interrupted and could not recover.",    async onExhausted(ctx) {      console.warn("Chat recovery exhausted", ctx.incidentId);    },  };
  getModel() {    /* ... */  }}
```

The same recovery events are available through `agents/observability` on the `chat` channel; transcript repairs are emitted on the `transcript` channel. Refer to [Observability](https://developers.cloudflare.com/agents/runtime/operations/observability/#chat-recovery-events).

## onChatRecovery

Override `onChatRecovery` when you need provider-specific recovery, such as retrieving a stored OpenAI Responses result instead of issuing a new model call:

* [  JavaScript ](#tab-panel-5859)
* [  TypeScript ](#tab-panel-5860)

JavaScript

```
export class MyAgent extends Think {  chatRecovery = {    maxAttempts: 10,    terminalMessage: "The assistant was interrupted. Please try again.",  };
  async onChatRecovery(ctx) {    console.log("Recovering chat turn", ctx.incidentId, ctx.attempt);    return {}; // persist partial output and continue/retry when possible  }}
```

TypeScript

```
import type {  ChatRecoveryContext,  ChatRecoveryOptions,} from "@cloudflare/think";
export class MyAgent extends Think<Env> {  override chatRecovery = {    maxAttempts: 10,    terminalMessage: "The assistant was interrupted. Please try again.",  };
  override async onChatRecovery(    ctx: ChatRecoveryContext,  ): Promise<ChatRecoveryOptions> {    console.log("Recovering chat turn", ctx.incidentId, ctx.attempt);    return {}; // persist partial output and continue/retry when possible  }}
```

### ChatRecoveryContext

| Field           | Type                     | Description                                                                              |
| --------------- | ------------------------ | ---------------------------------------------------------------------------------------- |
| incidentId      | string                   | Stable ID for this recovery incident                                                     |
| attempt         | number                   | Current attempt number for this incident, starting at 1                                  |
| maxAttempts     | number                   | Configured attempt cap before terminal exhaustion                                        |
| recoveryKind    | "retry" \| "continue"    | Whether recovery will retry an unanswered user turn or continue a partial assistant turn |
| streamId        | string                   | The stream ID of the interrupted turn                                                    |
| requestId       | string                   | The request ID of the interrupted turn                                                   |
| partialText     | string                   | Text generated before the interruption                                                   |
| partialParts    | MessagePart\[\]          | Parts accumulated before the interruption                                                |
| recoveryData    | unknown \| null          | Data from this.stash() during the turn                                                   |
| messages        | UIMessage\[\]            | Current conversation history                                                             |
| lastBody        | Record<string, unknown>? | Body from the interrupted turn                                                           |
| lastClientTools | ClientToolSchema\[\]?    | Client tools from the interrupted turn                                                   |
| createdAt       | number                   | Epoch milliseconds when the turn started                                                 |

### ChatRecoveryOptions

| Field    | Type     | Description                                                     |
| -------- | -------- | --------------------------------------------------------------- |
| persist  | boolean? | Whether to persist the partial assistant message                |
| continue | boolean? | Whether to auto-continue with a new turn via continueLastTurn() |

With `persist: true`, the partial message is saved. With `continue: true`, Think calls `continueLastTurn()` after the agent reaches a stable state.

For pre-stream interruptions, where `ctx.streamId === ""` and `ctx.partialText === ""` but the latest persisted message is still the unanswered user message, Think retries that turn automatically unless `continue` is `false`.

TypeScript

```
onChatRecovery(ctx: ChatRecoveryContext): ChatRecoveryOptions {  if (!ctx.streamId && !ctx.partialText) {    console.log("Recovering a pre-stream interruption");  }  return {};}
```

Use `ctx.createdAt` to skip stale recoveries. For example, if the interrupted turn is older than a few minutes, return `{ continue: false }` so the partial response is preserved without starting an old continuation.

### Recovery budgets and limits

Instead of `chatRecovery = true`, assign an object to tune how long recovery is allowed to run and when it is given up on. A turn that keeps making forward progress is never terminated by the framework on its own — duration is not a bound. Recovery is only sealed by one of the limits in the following table.

* [  JavaScript ](#tab-panel-5863)
* [  TypeScript ](#tab-panel-5864)

JavaScript

```
export class MyAgent extends Think {  chatRecovery = {    maxAttempts: 10,    noProgressTimeoutMs: 5 * 60 * 1000,    maxRecoveryWork: Infinity,    terminalMessage: "The assistant was interrupted and could not recover.",    // Consulted from the second recovery attempt onward. Return false to stop.    // Called as `config.shouldKeepRecovering(ctx)`, so it is NOT bound to the    // agent instance — track real token/cost spend in your own store keyed by    // `ctx.recoveryRootRequestId`.    async shouldKeepRecovering(ctx) {      return (await getSpendForTurn(ctx.recoveryRootRequestId)) < MAX_SPEND;    },    async onExhausted(ctx) {      console.warn("Recovery exhausted", ctx.incidentId, ctx.reason);    },  };}
```

TypeScript

```
export class MyAgent extends Think<Env> {  override chatRecovery = {    maxAttempts: 10,    noProgressTimeoutMs: 5 * 60 * 1000,    maxRecoveryWork: Infinity,    terminalMessage: "The assistant was interrupted and could not recover.",    // Consulted from the second recovery attempt onward. Return false to stop.    // Called as `config.shouldKeepRecovering(ctx)`, so it is NOT bound to the    // agent instance — track real token/cost spend in your own store keyed by    // `ctx.recoveryRootRequestId`.    async shouldKeepRecovering(ctx) {      return (await getSpendForTurn(ctx.recoveryRootRequestId)) < MAX_SPEND;    },    async onExhausted(ctx) {      console.warn("Recovery exhausted", ctx.incidentId, ctx.reason);    },  };}
```

| Field                | Default          | Description                                                                                                                                                                     |
| -------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| maxAttempts          | 10               | Attempt cap. Resets on forward progress, so it catches a tight no-progress alarm loop, not a healthy long turn.                                                                 |
| stableTimeoutMs      | 10\_000          | How long an attempt waits for the isolate to reach stable state before rescheduling.                                                                                            |
| noProgressTimeoutMs  | 300\_000 (5 min) | Primary stuck-turn bound: max time without forward progress before sealing. **Resets on every progress-bearing attempt.**                                                       |
| maxRecoveryWork      | Infinity         | Runaway-loop guard: max produced content/tool units since the incident opened before a still-progressing turn is sealed. No cap by default.                                     |
| shouldKeepRecovering | —                | Caller policy consulted from the second attempt onward. Return false to stop recovery. The hook point for a token/cost budget (ctx.work is a coarse segment count, not tokens). |
| terminalMessage      | generic message  | Message shown to the user when recovery is given up on.                                                                                                                         |
| onExhausted          | —                | Called once when recovery is given up on. Inspect ctx.reason.                                                                                                                   |

`ctx.reason` on the exhausted hook is one of: `no_progress_timeout` (stuck), `max_attempts_exceeded` (no-progress alarm loop), `work_budget_exceeded` (runaway), `recovery_aborted` (your `shouldKeepRecovering` returned `false`), or `stable_timeout` (extreme churn). Refer to [Stream recovery](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/#stream-recovery) for the full shared reference — Think and `@cloudflare/ai-chat` use the same recovery configuration.

## Repairing interrupted tool calls

When a turn is interrupted mid-flight, the transcript can contain a tool call with no settled result. Before the next provider call, Think repairs each such call so the model does not silently re-run it and the provider does not reject the transcript with `AI_MissingToolResultsError`. The default flips the interrupted call to an errored tool result, so the record survives and conversion still has a tool result for it.

Override `repairInterruptedToolPart` to customize the repaired shape. The common case is a client-resolved tool — for example an `ask_user` question that has no server `execute` and is normally answered by the user's next message. Converting it to a plain text part lets the model treat it as ordinary conversation rather than a tool error, and keeps the question verbatim through compaction:

* [  JavaScript ](#tab-panel-5865)
* [  TypeScript ](#tab-panel-5866)

JavaScript

```
export class MyAgent extends Think {  repairInterruptedToolPart(part) {    const record = part;    if (record.type === "tool-ask_user") {      const input = record.input;      if (input?.prompt) {        return { type: "text", text: input.prompt };      }    }    return super.repairInterruptedToolPart(part);  }}
```

TypeScript

```
import type { UIMessage } from "ai";
export class MyAgent extends Think<Env> {  protected override repairInterruptedToolPart(    part: UIMessage["parts"][number],  ): UIMessage["parts"][number] {    const record = part as Record<string, unknown>;    if (record.type === "tool-ask_user") {      const input = record.input as { prompt?: string } | undefined;      if (input?.prompt) {        return { type: "text", text: input.prompt };      }    }    return super.repairInterruptedToolPart(part);  }}
```

This runs during transcript repair — before the repaired transcript is persisted and sent to the model — so the conversion shapes the current turn, not just the next one. The `input` is already normalized to a valid object. A returned tool part must carry a settled result (`output-available`, `output-error`, or `output-denied`); returning a non-tool part such as text is also fine.

## Context-window overflow recovery

[Compaction](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/#compaction) is checked **between turns** — `compactAfter()` runs after each `appendMessage()`. But a single long, tool-heavy turn grows the prompt step by step inside one `streamText` loop and can exceed the model context window **mid-turn**, before the next pre-turn check. The provider then rejects the request (`"prompt is too long"`, `context_length_exceeded`), and the turn would otherwise die terminally.

Think recovers from this with two opt-in, provider-agnostic layers, both configured through the `contextOverflow` property. Both are off by default, so existing behavior is unchanged. Both reuse your session's compaction function, so they require a `configureSession()` with `onCompaction()` configured. Both require [classifyChatError](https://developers.cloudflare.com/agents/harnesses/think/lifecycle-hooks/#classifychaterror) to tell Think which errors are overflows — Think ships no provider-specific matching in core.

**1\. Reactive backstop — `contextOverflow.reactive`.** When a turn fails with an error you classify as `"context_overflow"`, Think discards the truncated partial, runs `session.compact()`, and re-runs the turn from the compacted history. The partial is not persisted: the turn restarts from scratch, so keeping the cut-off assistant message would orphan it beside the recovered answer. It is bounded by `contextOverflow.maxRetries` (default `1`); if compaction cannot shorten history or the budget is spent, the overflow surfaces terminally through `onChatError` with `classification: "context_overflow"` — it never loops or ends silently.

* [  JavaScript ](#tab-panel-5857)
* [  TypeScript ](#tab-panel-5858)

JavaScript

```
import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";
export class MyAgent extends Think {  contextOverflow = { reactive: true };
  // The bundled classifier covers the common providers (Anthropic, OpenAI,  // Google, Bedrock, …). Assign it directly, or write your own.  classifyChatError = defaultContextOverflowClassifier;}
```

TypeScript

```
import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";
export class MyAgent extends Think<Env> {  override contextOverflow = { reactive: true };
  // The bundled classifier covers the common providers (Anthropic, OpenAI,  // Google, Bedrock, …). Assign it directly, or write your own.  override classifyChatError = defaultContextOverflowClassifier;}
```

**2\. Proactive guard — `contextOverflow.proactive`.** Heads off the provider error before it happens. Before each step, Think reads the previous step's model-reported `usage.inputTokens` (provider-agnostic) and, if it crosses `maxInputTokens * (headroom ?? 0.9)`, compacts in place and feeds the recompacted history into the upcoming step. If a provider omits `inputTokens`, it falls back to `usage.totalTokens` (a safe over-approximation — it compacts slightly early rather than missing the threshold). It compacts at most `proactive.maxCompactions` times per turn (default `1`) — independent of the reactive `maxRetries` budget — so a history that cannot shorten does not compact on every step.

* [  JavaScript ](#tab-panel-5861)
* [  TypeScript ](#tab-panel-5862)

JavaScript

```
import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";
export class MyAgent extends Think {  contextOverflow = {    reactive: true,    // Compact mid-turn once a step approaches 90% of a 200K window.    proactive: { maxInputTokens: 200_000 },  };
  classifyChatError = defaultContextOverflowClassifier;}
```

TypeScript

```
import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";
export class MyAgent extends Think<Env> {  override contextOverflow = {    reactive: true,    // Compact mid-turn once a step approaches 90% of a 200K window.    proactive: { maxInputTokens: 200_000 },  };
  override classifyChatError = defaultContextOverflowClassifier;}
```

Use either layer alone, or both together: the proactive guard avoids most overflows, and the reactive backstop catches any that still slip through (for example, a turn that starts already over budget, or a single tool result so large that compaction cannot help — in which case it terminalizes cleanly). Both apply to every turn entry path (WebSocket, sub-agent `chat()`, and programmatic `saveMessages()` / `submitMessages()`), and both emit a `chat:context:compacted` [observability event](https://developers.cloudflare.com/agents/runtime/operations/observability/#chat-context-events).

Note

A no-op compaction cannot rescue an over-budget turn, so recovery is only as effective as your compaction configuration. For tool-heavy histories, configure a `tokenCounter` on `compactAfter()` (refer to [Sessions](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/#auto-compaction)).

For a runnable demo against a real Workers AI model, refer to the [context-overflow-recovery example ↗](https://github.com/cloudflare/agents/tree/main/examples/context-overflow-recovery).

## Stability detection

Think provides methods to check if the agent is in a stable state — no pending tool results, no pending approvals, no active turns.

### hasPendingInteraction

Returns `true` if any assistant message has pending tool calls (tools without results or pending approvals).

TypeScript

```
protected hasPendingInteraction(): boolean
```

### waitUntilStable

Returns a promise that resolves to `true` when the agent reaches a stable state, or `false` if the timeout is exceeded.

* [  JavaScript ](#tab-panel-5867)
* [  TypeScript ](#tab-panel-5868)

JavaScript

```
const stable = await this.waitUntilStable({ timeout: 30_000 });if (stable) {  await this.saveMessages([    {      id: crypto.randomUUID(),      role: "user",      parts: [{ type: "text", text: "Now that you are done, summarize." }],    },  ]);}
```

TypeScript

```
const stable = await this.waitUntilStable({ timeout: 30_000 });if (stable) {  await this.saveMessages([    {      id: crypto.randomUUID(),      role: "user",      parts: [{ type: "text", text: "Now that you are done, summarize." }],    },  ]);}
```

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/think/recovery/#page","headline":"Durable recovery · Cloudflare Agents docs","description":"Bounded chat recovery, the stream-stall watchdog, repairing interrupted tool calls, and stability detection for Think agents.","url":"https://developers.cloudflare.com/agents/harnesses/think/recovery/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-05","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/harnesses/think/recovery/","name":"Durable recovery"}}]}
```

---

---
title: Scheduled tasks
description: Declare recurring, timezone-aware Think turns and deterministic handlers with getScheduledTasks() and a typed scheduling DSL.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Scheduled tasks

Use `getScheduledTasks()` when code should create recurring Think turns or deterministic scheduled handlers. Think reconciles the declarations on startup, stores a durable one-shot schedule for the next occurrence, and re-arms the next occurrence after each run.

* [  JavaScript ](#tab-panel-5869)
* [  TypeScript ](#tab-panel-5870)

JavaScript

```
import { Think, defineScheduledTasks } from "@cloudflare/think";
export class DigestAgent extends Think {  getDefaultTimezone() {    return "Europe/London";  }
  getScheduledTasks() {    return defineScheduledTasks({      weeklyCommitReport: {        schedule: "every week on monday at 09:00",        prompt:          "Compile all my GitHub commits for the last week and send a concise summary.",      },      workout: {        schedule: "every day at 08:00 in Europe/London",        prompt: "Start my workout.",      },      customerDigest: {        schedule: "every day at 09:00",        timezone: "America/New_York",        metadata: { workflowName: "customer-digest" },        retry: { maxAttempts: 3 },        handler: async ({          idempotencyKey,          scheduledFor,          scheduleKind,          timezone,        }) => {          await this.env.DIGEST_WORKFLOW.create({            id: idempotencyKey,            params: { scheduledFor, scheduleKind, timezone },          });        },      },    });  }}
```

TypeScript

```
import { Think, defineScheduledTasks } from "@cloudflare/think";
export class DigestAgent extends Think<Env> {  getDefaultTimezone() {    return "Europe/London";  }
  getScheduledTasks() {    return defineScheduledTasks({      weeklyCommitReport: {        schedule: "every week on monday at 09:00",        prompt:          "Compile all my GitHub commits for the last week and send a concise summary.",      },      workout: {        schedule: "every day at 08:00 in Europe/London",        prompt: "Start my workout.",      },      customerDigest: {        schedule: "every day at 09:00",        timezone: "America/New_York",        metadata: { workflowName: "customer-digest" },        retry: { maxAttempts: 3 },        handler: async ({          idempotencyKey,          scheduledFor,          scheduleKind,          timezone,        }) => {          await this.env.DIGEST_WORKFLOW.create({            id: idempotencyKey,            params: { scheduledFor, scheduleKind, timezone },          });        },      },    });  }}
```

The DSL supports `every <n> minutes`, `every <n> hours`, `every day at HH:mm`, `every weekday at HH:mm`, and `every week on monday,wednesday at HH:mm`. Wall-clock schedules require either an inline timezone, a task `timezone`, or `getDefaultTimezone()`. If an alarm is late, Think runs the intended occurrence once and schedules the next future occurrence; it does not backfill missed runs.

Each task must define exactly one of `prompt` or `handler`. Prompt tasks create a durable submission with [submitMessages()](https://developers.cloudflare.com/agents/harnesses/think/programmatic-submissions/). Handler tasks receive `{ taskId, scheduledFor, scheduledForDate, occurrenceKey, idempotencyKey, schedule, scheduleKind, timezone, metadata }` and are intended for app-owned work such as creating a Workflow run or writing a run ledger. Delivery is at-least-once; use `idempotencyKey` or `occurrenceKey` for your own durable idempotency.

Static declarations reconcile on startup. If `getScheduledTasks()` reads product-owned data that can change while the Durable Object is live, call `internal_reconcileScheduledTasks()` after updating that data. During reconciliation Think records the task row before creating the underlying Agent schedule, so a missing `schedule_id` is only a pending reconcile state and is repaired on the next reconcile. The task `retry` option retries the prompt or handler action before the failure is logged. The next occurrence is still scheduled after the action succeeds or exhausts its retries, so failed occurrences do not block future runs.

## When to use a workflow instead

For a recurring job whose steps matter — multiple deterministic steps, long waits, or human approval — use a handler task to create a [Think Workflow](https://developers.cloudflare.com/agents/harnesses/think/workflows/) run. Keep simple recurring prompts as prompt tasks, and keep one-off background turns on [submitMessages()](https://developers.cloudflare.com/agents/harnesses/think/programmatic-submissions/).

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/think/scheduled-tasks/#page","headline":"Scheduled tasks · Cloudflare Agents docs","description":"Declare recurring, timezone-aware Think turns and deterministic handlers with getScheduledTasks() and a typed scheduling DSL.","url":"https://developers.cloudflare.com/agents/harnesses/think/scheduled-tasks/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/harnesses/think/scheduled-tasks/","name":"Scheduled tasks"}}]}
```

---

---
title: Sub-agent RPC and programmatic turns
description: Stream Think turns through a child agent with chat(), and trigger turns programmatically with saveMessages(), continueLastTurn(), and abort.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Sub-agent RPC and programmatic turns

Think works as both a top-level agent and a sub-agent. When used as a sub-agent, the `chat()` method runs a full turn and streams events via a callback.

Note

This page covers calling Think from server code instead of a browser — multi-agent systems, scheduled or webhook-triggered turns, and recovery. If you are building a single chat agent that users talk to in the browser, you can skip it; `useAgentChat` (see [Getting started](https://developers.cloudflare.com/agents/harnesses/think/getting-started/)) is all you need.

For durable acceptance with idempotent retry and later status inspection, refer to [Programmatic submissions](https://developers.cloudflare.com/agents/harnesses/think/programmatic-submissions/). For recovery after eviction, refer to [Durable recovery](https://developers.cloudflare.com/agents/harnesses/think/recovery/).

## chat

TypeScript

```
async chat(  userMessage: string | UIMessage,  callback: StreamCallback,  options?: ChatOptions,): Promise<void>
```

### StreamCallback

| Method           | When it fires                                                                                                                                                        |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| onStart(event)   | Before work starts; exposes the request ID for cancellation                                                                                                          |
| onEvent(json)    | For each streaming chunk (JSON-serialized UIMessageChunk)                                                                                                            |
| onDone()         | After the turn completes and the assistant message is persisted                                                                                                      |
| onError(message) | On error during the turn                                                                                                                                             |
| onInterrupted()  | Optional. The attempt was interrupted and a scheduled continuation (in a later isolate) owns the final outcome — not done, not a terminal error. Defaults to a no-op |

`onInterrupted` matters for a `chat()`\-driven turn that is interrupted and recovers: the RPC promise resolves **cleanly** (the isolate is still alive), so a consumer that keys off the clean resolve would mis-read it as success and finalize whatever partial it had streamed. Treat it as "not done, not failed — a continuation owns the answer": keep the channel open, show a recovering state, or re-attach, rather than finalizing the partial. A deploy or eviction interruption kills the isolate before this can fire (the caller sees a transport break instead); `onInterrupted` covers the in-isolate stall-into-recovery path.

### ChatOptions

| Field  | Description                               |
| ------ | ----------------------------------------- |
| signal | AbortSignal to cancel the turn mid-stream |

Tools belong to the child agent. Define durable capabilities with the child's `getTools()`, extensions, MCP tools, or client tool schemas. Legacy callers that pass `options.tools` to `chat()` receive a warning and the value is ignored.

### Example: parent calling a child

* [  JavaScript ](#tab-panel-5881)
* [  TypeScript ](#tab-panel-5882)

JavaScript

```
import { Think } from "@cloudflare/think";
export class ParentAgent extends Think {  getModel() {    /* ... */  }
  async delegateToChild(task) {    const child = await this.subAgent(ChildAgent, "child-1");
    const chunks = [];    await child.chat(task, {      onStart: (event) => {        console.log("Child started:", event.requestId);      },      onEvent: (json) => {        chunks.push(json);      },      onDone: () => {        console.log("Child completed");      },      onError: (error) => {        console.error("Child failed:", error);      },    });
    return chunks;  }}
export class ChildAgent extends Think {  getModel() {    /* ... */  }
  getSystemPrompt() {    return "You are a research assistant. Analyze data and report findings.";  }}
```

TypeScript

```
import { Think } from "@cloudflare/think";import type { StreamCallback } from "@cloudflare/think";
export class ParentAgent extends Think<Env> {  getModel() {    /* ... */  }
  async delegateToChild(task: string) {    const child = await this.subAgent(ChildAgent, "child-1");
    const chunks: string[] = [];    await child.chat(task, {      onStart: (event) => {        console.log("Child started:", event.requestId);      },      onEvent: (json) => {        chunks.push(json);      },      onDone: () => {        console.log("Child completed");      },      onError: (error) => {        console.error("Child failed:", error);      },    });
    return chunks;  }}
export class ChildAgent extends Think<Env> {  getModel() {    /* ... */  }
  getSystemPrompt() {    return "You are a research assistant. Analyze data and report findings.";  }}
```

### Cancelling a sub-agent turn

Use `onStart` and `cancelChat()` for RPC-safe cancellation across a sub-agent boundary:

* [  JavaScript ](#tab-panel-5877)
* [  TypeScript ](#tab-panel-5878)

JavaScript

```
let requestId;
const callback = {  onStart(event) {    requestId = event.requestId;  },  onEvent(json) {    // Forward stream chunks.  },  onDone() {},  onError(error) {    console.error(error);  },};
const turn = child.chat("Long analysis task", callback);
// Later, from another RPC call or failure handler:if (requestId) {  await child.cancelChat(requestId, "client disconnected");}
await turn;
```

TypeScript

```
let requestId: string | undefined;
const callback: StreamCallback = {  onStart(event) {    requestId = event.requestId;  },  onEvent(json) {    // Forward stream chunks.  },  onDone() {},  onError(error) {    console.error(error);  },};
const turn = child.chat("Long analysis task", callback);
// Later, from another RPC call or failure handler:if (requestId) {  await child.cancelChat(requestId, "client disconnected");}
await turn;
```

If the caller and callee are not separated by Workers RPC, you can also pass an `AbortSignal` to cancel mid-stream:

* [  JavaScript ](#tab-panel-5871)
* [  TypeScript ](#tab-panel-5872)

JavaScript

```
const controller = new AbortController();setTimeout(() => controller.abort(), 30_000);
await child.chat("Long analysis task", callback, {  signal: controller.signal,});
```

TypeScript

```
const controller = new AbortController();setTimeout(() => controller.abort(), 30_000);
await child.chat("Long analysis task", callback, {  signal: controller.signal,});
```

`cancelChat(requestId, reason?)` is a no-op if the turn already completed or the request ID is unknown. When aborted, the partial assistant message is still persisted.

## saveMessages

Inject messages and trigger a model turn without a WebSocket connection. Use for scheduled responses, webhook-triggered turns, proactive agents, or chaining from `onChatResponse`.

TypeScript

```
async saveMessages(  messages:    | UIMessage[]    | ((current: UIMessage[]) => UIMessage[] | Promise<UIMessage[]>),  options?: SaveMessagesOptions,): Promise<SaveMessagesResult>
```

Returns `{ requestId, status, error? }` where `status` is `"completed"`, `"error"`, `"skipped"`, or `"aborted"`.

| status      | When                                                                                                                  |
| ----------- | --------------------------------------------------------------------------------------------------------------------- |
| "completed" | Turn ran to completion.                                                                                               |
| "error"     | Turn started but the stream reported an error. error contains the stream error message when available.                |
| "skipped"   | Turn invalidated mid-flight, for example by chat-clear; user message persisted, no model run.                         |
| "aborted"   | Turn cancelled before completion via options.signal or chat-request-cancel. Partial assistant chunks still persisted. |

Pass `options.signal` to cancel a programmatic turn from the Durable Object that starts it. `AbortSignal` cannot cross Durable Object RPC boundaries, and the signal is not persisted across hibernation.

### Static messages

* [  JavaScript ](#tab-panel-5873)
* [  TypeScript ](#tab-panel-5874)

JavaScript

```
await this.saveMessages([  {    id: crypto.randomUUID(),    role: "user",    parts: [{ type: "text", text: "Time for your daily summary." }],  },]);
```

TypeScript

```
await this.saveMessages([  {    id: crypto.randomUUID(),    role: "user",    parts: [{ type: "text", text: "Time for your daily summary." }],  },]);
```

### Function form

When multiple `saveMessages` calls queue up, the function form runs with the latest messages when the turn actually starts:

* [  JavaScript ](#tab-panel-5875)
* [  TypeScript ](#tab-panel-5876)

JavaScript

```
await this.saveMessages((current) => [  ...current,  {    id: crypto.randomUUID(),    role: "user",    parts: [{ type: "text", text: "Continue your analysis." }],  },]);
```

TypeScript

```
await this.saveMessages((current) => [  ...current,  {    id: crypto.randomUUID(),    role: "user",    parts: [{ type: "text", text: "Continue your analysis." }],  },]);
```

### Scheduled responses

Trigger a recurring prompt turn with [getScheduledTasks()](https://developers.cloudflare.com/agents/harnesses/think/scheduled-tasks/):

* [  JavaScript ](#tab-panel-5879)
* [  TypeScript ](#tab-panel-5880)

JavaScript

```
export class MyAgent extends Think {  getModel() {    /* ... */  }
  getScheduledTasks() {    return {      dailyReport: {        schedule: "every day at 09:00",        timezone: "UTC",        prompt: "Generate the daily report.",      },    };  }}
```

TypeScript

```
export class MyAgent extends Think<Env> {  getModel() {    /* ... */  }
  getScheduledTasks() {    return {      dailyReport: {        schedule: "every day at 09:00",        timezone: "UTC",        prompt: "Generate the daily report.",      },    };  }}
```

### Chaining from onChatResponse

Start a follow-up turn after the current one completes:

TypeScript

```
async onChatResponse(result: ChatResponseResult) {  if (result.status === "completed" && this.needsFollowUp(result.message)) {    await this.saveMessages([{      id: crypto.randomUUID(),      role: "user",      parts: [{ type: "text", text: "Now summarize what you found." }],    }]);  }}
```

## continueLastTurn

Run another model call after the latest assistant message without injecting a new user message. Think persists the result as a new assistant message with `continuation: true`; it does not append chunks to the existing assistant message.

TypeScript

```
protected async continueLastTurn(  body?: Record<string, unknown>,  options?: SaveMessagesOptions,): Promise<SaveMessagesResult>
```

Returns `{ requestId, status: "skipped" }` if the last message is not an assistant message. The optional `body` parameter overrides the stored body for this continuation. Pass `options.signal` to cancel the continuation while it is running.

## abortRequest and abortAllRequests

Cancel in-flight chat turns from inside the Durable Object:

TypeScript

```
protected abortRequest(requestId: string, reason?: unknown): voidprotected abortAllRequests(): void
```

Use `abortRequest()` when you know the request ID. Use `abortAllRequests()` for single-purpose helpers that should cancel whatever turn is currently running. Prefer `SaveMessagesOptions.signal` for programmatic turns when you can pass a signal at the call site.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/think/sub-agents/#page","headline":"Sub-agent RPC and programmatic turns · Cloudflare Agents docs","description":"Stream Think turns through a child agent with chat(), and trigger turns programmatically with saveMessages(), continueLastTurn(), and abort.","url":"https://developers.cloudflare.com/agents/harnesses/think/sub-agents/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/harnesses/think/sub-agents/","name":"Sub-agent RPC and programmatic turns"}}]}
```

---

---
title: Tools
description: Built-in workspace tools (including bash), custom tools, approvals, MCP tools, code execution, browser tools, and extensions for Think agents.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Tools

Think provides built-in workspace file tools on every turn, plus integration points for custom tools, code execution, and dynamic extensions.

## Tool merge order

On every turn, Think merges tools from multiple sources. Later sources override earlier ones if names collide:

1. **Workspace tools** — `read`, `write`, `edit`, `list`, `find`, `grep`, `delete`, `bash` (built-in)
2. **`getTools()`** — your custom server-side tools
3. **Extension tools** — tools from loaded extensions (prefixed by extension name)
4. **Session tools** — `set_context`, `load_context`, `search_context` (from `configureSession`)
5. **Skill tools** — `activate_skill`, `read_skill_resource`, `run_skill_script` (from `getSkills()`, refer to [Agent Skills](https://developers.cloudflare.com/agents/runtime/execution/agent-skills/))
6. **MCP tools** — from connected MCP servers
7. **Client tools** — from the browser (refer to [Client tools](https://developers.cloudflare.com/agents/harnesses/think/client-tools/))

Tools belong to the agent running the turn. For parent-child orchestration, use [Agents as tools](https://developers.cloudflare.com/agents/runtime/execution/agent-tools/) instead of passing one-off tools through `chat()`.

## Built-in workspace tools

Every Think agent gets `this.workspace` — a virtual filesystem backed by Durable Object SQLite. Workspace tools are automatically available to the model with no configuration.

| Tool   | Description                                                                 |
| ------ | --------------------------------------------------------------------------- |
| read   | Read text with line numbers; pass images and PDFs to multimodal models      |
| write  | Write content to a file (creates parent directories)                        |
| edit   | Apply a find-and-replace edit to an existing file (supports fuzzy matching) |
| list   | List files and directories in a path                                        |
| find   | Find files matching a glob pattern                                          |
| grep   | Search file contents by regex or fixed string                               |
| delete | Delete a file or directory                                                  |
| bash   | Run a sandboxed Bash script against workspace files                         |

The `bash` tool is enabled by default. It mounts workspace files into a `just-bash` virtual filesystem, runs with network access disabled, and writes created, updated, and deleted files and empty directories back to the workspace. Use it for shell-style workflows that combine multiple file operations; use the narrower tools for simple reads, writes, and edits.

To keep tool calls bounded, the Bash tool snapshots up to 1,000 workspace files by default and skips files larger than 1 MB. Skipped files are reported in the tool result and are treated as protected during write-back so the script cannot accidentally overwrite or delete content that was not mounted. You can tune `maxWorkspaceFiles`, `maxWorkspaceFileBytes`, `maxOutputBytes`, `timeout`, and `network` through `workspaceBash`.

Disable the default Bash tool for conservative deployments:

* [  JavaScript ](#tab-panel-5887)
* [  TypeScript ](#tab-panel-5888)

JavaScript

```
export class MyAgent extends Think {  workspaceBash = false;
  getModel() {    /* ... */  }}
```

TypeScript

```
export class MyAgent extends Think<Env> {  workspaceBash = false;
  getModel() {    /* ... */  }}
```

### R2 spillover

By default, the workspace stores everything in SQLite. For large files, override `workspace` to add R2 spillover:

* [  JavaScript ](#tab-panel-5895)
* [  TypeScript ](#tab-panel-5896)

JavaScript

```
import { Think } from "@cloudflare/think";import { Workspace } from "@cloudflare/shell";
export class MyAgent extends Think {  workspace = new Workspace({    sql: this.ctx.storage.sql,    r2: this.env.R2,    name: () => this.name,  });
  getModel() {    /* ... */  }}
```

TypeScript

```
import { Think } from "@cloudflare/think";import { Workspace } from "@cloudflare/shell";
export class MyAgent extends Think<Env> {  override workspace = new Workspace({    sql: this.ctx.storage.sql,    r2: this.env.R2,    name: () => this.name,  });
  getModel() {    /* ... */  }}
```

This requires an R2 bucket binding:

* [  wrangler.jsonc ](#tab-panel-5883)
* [  wrangler.toml ](#tab-panel-5884)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  "r2_buckets": [    {      "binding": "R2",      "bucket_name": "agent-files"    }  ]}
```

TOML

```
[[r2_buckets]]binding = "R2"bucket_name = "agent-files"
```

## Custom tools

Override `getTools()` to add your own tools. These are standard AI SDK `tool()` definitions with Zod schemas:

* [  JavaScript ](#tab-panel-5909)
* [  TypeScript ](#tab-panel-5910)

JavaScript

```
import { Think } from "@cloudflare/think";import { tool } from "ai";
import { z } from "zod";
export class MyAgent extends Think {  getModel() {    /* ... */  }
  getTools() {    return {      getWeather: tool({        description: "Get the current weather for a city",        inputSchema: z.object({          city: z.string().describe("City name"),        }),        execute: async ({ city }) => {          const res = await fetch(            `https://api.weather.com/v1/current?q=${city}&key=${this.env.WEATHER_KEY}`,          );          return res.json();        },      }),    };  }}
```

TypeScript

```
import { Think } from "@cloudflare/think";import { tool } from "ai";import type { ToolSet } from "ai";import { z } from "zod";
export class MyAgent extends Think<Env> {  getModel() {    /* ... */  }
  getTools(): ToolSet {    return {      getWeather: tool({        description: "Get the current weather for a city",        inputSchema: z.object({          city: z.string().describe("City name"),        }),        execute: async ({ city }) => {          const res = await fetch(            `https://api.weather.com/v1/current?q=${city}&key=${this.env.WEATHER_KEY}`,          );          return res.json();        },      }),    };  }}
```

Custom tools are merged with workspace tools automatically. If a custom tool has the same name as a workspace tool, the custom tool wins.

## Tool approval

Tools can require user approval before execution using the `needsApproval` option:

TypeScript

```
getTools(): ToolSet {  return {    deleteFile: tool({      description: "Delete a file from the system",      inputSchema: z.object({ path: z.string() }),      needsApproval: async ({ path }) => path.startsWith("/important/"),      execute: async ({ path }) => {        await this.workspace.rm(path);        return { deleted: path };      },    }),  };}
```

When `needsApproval` returns `true`, the tool call is sent to the client for approval. The conversation pauses until the client responds with `CF_AGENT_TOOL_APPROVAL`.

Note

Inside the [code execution tool](#code-execution-tool)'s sandbox, `needsApproval` behaves differently: it maps to the Code Mode runtime's durable pause/approve/resume flow, and a function-valued `needsApproval` always requires approval. Refer to [Approvals (human-in-the-loop)](#approvals-human-in-the-loop).

## Per-turn tool overrides

The `beforeTurn` hook can restrict or add tools for a specific turn:

TypeScript

```
beforeTurn(ctx: TurnContext) {  return {    activeTools: ["read", "write", "getWeather"],    tools: { emergencyTool: this.createEmergencyTool() },  };}
```

`activeTools` limits which tools the model can call. `tools` adds extra tools for this turn only (merged on top of existing tools).

## MCP tools

Think inherits MCP client support from the `Agent` base class. MCP tools from connected servers are automatically merged into every turn.

Set `waitForMcpConnections` to ensure MCP servers are connected before inference runs:

* [  JavaScript ](#tab-panel-5891)
* [  TypeScript ](#tab-panel-5892)

JavaScript

```
export class MyAgent extends Think {  waitForMcpConnections = true; // default 10s timeout  // or: waitForMcpConnections = { timeout: 5000 };
  getModel() {    /* ... */  }}
```

TypeScript

```
export class MyAgent extends Think<Env> {  waitForMcpConnections = true; // default 10s timeout  // or: waitForMcpConnections = { timeout: 5000 };
  getModel() {    /* ... */  }}
```

Add MCP servers programmatically or via `@callable` methods:

* [  JavaScript ](#tab-panel-5903)
* [  TypeScript ](#tab-panel-5904)

JavaScript

```
import { callable } from "agents";
export class MyAgent extends Think {  getModel() {    /* ... */  }
  @callable()  async addServer(name, url) {    return await this.addMcpServer(name, url);  }
  @callable()  async removeServer(serverId) {    await this.removeMcpServer(serverId);  }}
```

TypeScript

```
import { callable } from "agents";
export class MyAgent extends Think<Env> {  getModel() {    /* ... */  }
  @callable()  async addServer(name: string, url: string) {    return await this.addMcpServer(name, url);  }
  @callable()  async removeServer(serverId: string) {    await this.removeMcpServer(serverId);  }}
```

## Code execution tool

Let the LLM write and run JavaScript in a sandboxed Worker, recorded on a durable Code Mode runtime (abort-and-replay, human approvals, audit trail, reusable snippets). Requires `@cloudflare/codemode` and a `worker_loaders` binding.

Terminal window

```
npm install @cloudflare/codemode
```

The one-liner infers everything from the agent — `state.*` from `this.workspace`, the executor from `env.LOADER`, and a live browser (`cdp.*`) from `env.BROWSER` if bound:

* [  JavaScript ](#tab-panel-5899)
* [  TypeScript ](#tab-panel-5900)

JavaScript

```
import { Think } from "@cloudflare/think";import { createExecuteTool } from "@cloudflare/think/tools/execute";
export class MyAgent extends Think {  getModel() {    /* ... */  }
  getTools() {    return {      execute: createExecuteTool(this),    };  }}
```

TypeScript

```
import { Think } from "@cloudflare/think";import { createExecuteTool } from "@cloudflare/think/tools/execute";
export class MyAgent extends Think<Env> {  getModel() {    /* ... */  }
  getTools() {    return {      execute: createExecuteTool(this),    };  }}
```

Setup checklist:

* [  wrangler.jsonc ](#tab-panel-5885)
* [  wrangler.toml ](#tab-panel-5886)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  "worker_loaders": [    {      "binding": "LOADER"    }  ],  "browser": {    "binding": "BROWSER"  }}
```

TOML

```
[[worker_loaders]]binding = "LOADER"
[browser]binding = "BROWSER" # optional — enables cdp.*
```

* [  JavaScript ](#tab-panel-5897)
* [  TypeScript ](#tab-panel-5898)

JavaScript

```
// worker entry — the runtime lives in a Durable Object facet, so the class// must be exported (the @cloudflare/codemode/vite plugin does this// automatically; the Think framework's generated entry already includes it)export { CodemodeRuntime } from "@cloudflare/codemode";
```

TypeScript

```
// worker entry — the runtime lives in a Durable Object facet, so the class// must be exported (the @cloudflare/codemode/vite plugin does this// automatically; the Think framework's generated entry already includes it)export { CodemodeRuntime } from "@cloudflare/codemode";
```

Each missing piece fails with an error naming the step.

Inside the sandbox the model sees typed namespaces plus the platform SDK:

* `tools.*` — your AI SDK tools (object args, validated against their schemas). Only tools with an `execute` function are exposed — client-side tools cannot run in the sandbox.
* `state.*` — the workspace filesystem (`state.readFile({ path })`, `state.glob({ pattern })`, `state.planEdits(...)`, and so on).
* `cdp.*` — the browser, when a Browser Run binding is configured. The execute tool defaults to `session: { mode: "dynamic" }`: sessions are per-execution unless the model promotes one with `cdp.startSession()`.
* `codemode.search` / `codemode.describe` / `codemode.step` / `codemode.run` — discovery, side-effect boundaries, and saved snippets.

Pass overrides for anything beyond the defaults — for example, custom `tools.*` alongside the agent-derived state:

* [  JavaScript ](#tab-panel-5893)
* [  TypeScript ](#tab-panel-5894)

JavaScript

```
execute: createExecuteTool(this, { tools: myDomainTools });
```

TypeScript

```
execute: createExecuteTool(this, { tools: myDomainTools });
```

Or fully explicit options (no agent inference):

* [  JavaScript ](#tab-panel-5905)
* [  TypeScript ](#tab-panel-5906)

JavaScript

```
import { createWorkspaceStateBackend } from "@cloudflare/shell";
createExecuteTool({  ctx: this.ctx,  tools: myDomainTools,  state: createWorkspaceStateBackend(this.workspace),  browser: this.env.BROWSER,  loader: this.env.LOADER,});
```

TypeScript

```
import { createWorkspaceStateBackend } from "@cloudflare/shell";
createExecuteTool({  ctx: this.ctx,  tools: myDomainTools,  state: createWorkspaceStateBackend(this.workspace),  browser: this.env.BROWSER,  loader: this.env.LOADER,});
```

### Approvals (human-in-the-loop)

An AI SDK tool with `needsApproval` does not run immediately inside the sandbox — calling it **pauses the run durably**. The pause comes back as a normal tool output (`{ status: "paused", executionId, pending }`), the model tells the user what it needs, and the turn ends. This differs from the client-side approval flow for plain `getTools()` tools: inside the sandbox a function-valued `needsApproval` cannot be evaluated against the call's arguments ahead of time, so it conservatively **always** requires approval. Think ships built-in callables to resolve it:

* `approveExecution(executionId)` — resumes the run where it stopped. Already-done work is replayed, not re-executed. The outcome replaces the paused output in the transcript and the chat auto-continues.
* `rejectExecution(executionId, reason?)` — ends the run with `{ status: "rejected", reason }` so the model can adapt.
* `pendingExecutions()` — pending actions (with full args) for rendering approval UI.

Note

**Render approval cards from `pendingExecutions()`, not the transcript.** The `pending` array in the paused tool output is a _truncated preview_ — args are bounded (\~2 KB each) so they do not blow up model context, but the full args (up to 1 MB) are what actually execute on approve. A human approving a gated call must see the authoritative args, so fetch them via `pendingExecutions(executionId)` before enabling the Approve button.

For a working approval card, refer to the [assistant example ↗](https://github.com/cloudflare/agents/tree/main/examples/assistant).

### The runtime handle

`createExecuteRuntime` returns the moving parts when the host needs more than the tool — and the handle is also assigned to `this.codemode` when created from an agent:

* [  JavaScript ](#tab-panel-5901)
* [  TypeScript ](#tab-panel-5902)

JavaScript

```
import { createExecuteRuntime } from "@cloudflare/think/tools/execute";
const { runtime, connectors, tool } = createExecuteRuntime(this);await runtime.executions(); // audit trailawait runtime.expirePaused(); // reclaim stale never-approved pauses (call from a scheduled task)await runtime.saveSnippet("name", { executionId }); // promote a script for reuse
```

TypeScript

```
import { createExecuteRuntime } from "@cloudflare/think/tools/execute";
const { runtime, connectors, tool } = createExecuteRuntime(this);await runtime.executions(); // audit trailawait runtime.expirePaused(); // reclaim stale never-approved pauses (call from a scheduled task)await runtime.saveSnippet("name", { executionId }); // promote a script for reuse
```

## Browser tools

Give your agent access to the Chrome DevTools Protocol (CDP) for web page inspection, scraping, screenshots, and debugging. Requires `@cloudflare/codemode` and a Browser Run binding.

* [  JavaScript ](#tab-panel-5913)
* [  TypeScript ](#tab-panel-5914)

JavaScript

```
import { Think } from "@cloudflare/think";import { createBrowserTools } from "@cloudflare/think/tools/browser";
export class MyAgent extends Think {  getModel() {    /* ... */  }
  getTools() {    return {      ...createBrowserTools({        ctx: this.ctx,        browser: this.env.BROWSER,        loader: this.env.LOADER,      }),    };  }}
```

TypeScript

```
import { Think } from "@cloudflare/think";import { createBrowserTools } from "@cloudflare/think/tools/browser";
export class MyAgent extends Think<Env> {  getModel() {    /* ... */  }
  getTools() {    return {      ...createBrowserTools({        ctx: this.ctx,        browser: this.env.BROWSER,        loader: this.env.LOADER,      }),    };  }}
```

* [  wrangler.jsonc ](#tab-panel-5889)
* [  wrangler.toml ](#tab-panel-5890)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  "browser": {    "binding": "BROWSER"  },  "worker_loaders": [    {      "binding": "LOADER"    }  ]}
```

TOML

```
[browser]binding = "BROWSER"
[[worker_loaders]]binding = "LOADER"
```

This adds the durable CDP tool plus stateless [Quick Action](https://developers.cloudflare.com/agents/tools/browser/#quick-actions) tools when a `browser` binding is present:

| Tool              | Description                                                                             |
| ----------------- | --------------------------------------------------------------------------------------- |
| browser\_execute  | Run JavaScript against a live browser over CDP (screenshots, DOM reads, JS evaluation). |
| browser\_markdown | Read a page or raw HTML as Markdown.                                                    |
| browser\_extract  | Extract structured data from a page with AI.                                            |
| browser\_links    | List links on a page.                                                                   |
| browser\_scrape   | Scrape specific elements by CSS selector.                                               |

Pass `quickActions: false` to keep only `browser_execute`, or pass `quickActions: { actions, maxChars, options }` to configure the stateless tools. The Quick Action tools share the `browser` binding, need no Worker Loader, and resolve `ctx` from the current Agent automatically. To use only the stateless tools, import `createQuickActionTools` from `@cloudflare/think/tools/browser`.

The tool is backed by a Code Mode runtime with the `cdp` connector: the model writes async arrow functions that run in a sandboxed Worker isolate, with `cdp.send()`, `cdp.attachToTarget()`, `cdp.spec()` (the live, normalized protocol description), session helpers (`cdp.startSession()`, `cdp.sessionInfo()`, `cdp.closeSession()`), and debug-log helpers. Executions are recorded for abort-and-replay, so browser sessions survive approval pauses.

By default each execution gets a fresh browser session (`one-shot`), torn down when the run ends. Pass `session: { mode: "dynamic" }` to let the model promote a session with `cdp.startSession()` so later executions continue in the same browser, or `session: { mode: "reuse", key }` for a named long-lived session. Stale sessions are reclaimed by the connector's `sweep()` — call it from a scheduled task.

Note

The simplest setup is the unified execute tool in [Code execution tool](#code-execution-tool): `createExecuteTool(this)` already includes `cdp.*` alongside `state.*` and `tools.*` when `env.BROWSER` is bound — one tool, one durable history. Use `createBrowserTools` when you want a separate, browser-only tool.

For a custom Chrome endpoint, pass `cdpUrl` instead of `browser`:

* [  JavaScript ](#tab-panel-5907)
* [  TypeScript ](#tab-panel-5908)

JavaScript

```
createBrowserTools({  ctx: this.ctx,  cdpUrl: "http://localhost:9222",  loader: this.env.LOADER,});
```

TypeScript

```
createBrowserTools({  ctx: this.ctx,  cdpUrl: "http://localhost:9222",  loader: this.env.LOADER,});
```

For the full CDP connector API, refer to [Browse the web](https://developers.cloudflare.com/agents/tools/browser/).

## Extensions

Extensions are dynamically loaded sandboxed Workers that add tools at runtime. The LLM can write extension source code, load it, and use the new tools on the next turn.

Extensions require a `worker_loaders` binding:

* [  JavaScript ](#tab-panel-5911)
* [  TypeScript ](#tab-panel-5912)

JavaScript

```
import { Think } from "@cloudflare/think";
export class MyAgent extends Think {  extensionLoader = this.env.LOADER;
  getModel() {    /* ... */  }}
```

TypeScript

```
import { Think } from "@cloudflare/think";
export class MyAgent extends Think<Env> {  extensionLoader = this.env.LOADER;
  getModel() {    /* ... */  }}
```

### Static extensions

Define extensions that load at startup:

* [  JavaScript ](#tab-panel-5923)
* [  TypeScript ](#tab-panel-5924)

JavaScript

```
export class MyAgent extends Think {  extensionLoader = this.env.LOADER;
  getModel() {    /* ... */  }
  getExtensions() {    return [      {        manifest: {          name: "math",          version: "1.0.0",          permissions: { network: false },        },        source: `({          tools: {            add: {              description: "Add two numbers",              parameters: { a: { type: "number" }, b: { type: "number" } },              execute: async ({ a, b }) => ({ result: a + b })            }          }        })`,      },    ];  }}
```

TypeScript

```
export class MyAgent extends Think<Env> {  extensionLoader = this.env.LOADER;
  getModel() {    /* ... */  }
  getExtensions() {    return [      {        manifest: {          name: "math",          version: "1.0.0",          permissions: { network: false },        },        source: `({          tools: {            add: {              description: "Add two numbers",              parameters: { a: { type: "number" }, b: { type: "number" } },              execute: async ({ a, b }) => ({ result: a + b })            }          }        })`,      },    ];  }}
```

Extension tools are namespaced — a `math` extension with an `add` tool becomes `math_add` in the model's tool set.

### LLM-driven extensions

Give the model `createExtensionTools` so it can load extensions dynamically:

* [  JavaScript ](#tab-panel-5921)
* [  TypeScript ](#tab-panel-5922)

JavaScript

```
import { createExtensionTools } from "@cloudflare/think/tools/extensions";
export class MyAgent extends Think {  extensionLoader = this.env.LOADER;
  getModel() {    /* ... */  }
  getTools() {    return {      ...createExtensionTools({ manager: this.extensionManager }),      ...this.extensionManager.getTools(),    };  }}
```

TypeScript

```
import { createExtensionTools } from "@cloudflare/think/tools/extensions";
export class MyAgent extends Think<Env> {  extensionLoader = this.env.LOADER;
  getModel() {    /* ... */  }
  getTools() {    return {      ...createExtensionTools({ manager: this.extensionManager! }),      ...this.extensionManager!.getTools(),    };  }}
```

This gives the model two tools:

* `load_extension` — load a new extension from JavaScript source
* `list_extensions` — list currently loaded extensions

### Extension context blocks

Extensions can declare context blocks in their manifest. These are automatically registered with the Session:

TypeScript

```
getExtensions() {  return [{    manifest: {      name: "notes",      version: "1.0.0",      permissions: { network: false },      context: [        { label: "scratchpad", description: "Extension scratch space", maxTokens: 500 },      ],    },    source: `({ tools: { /* ... */ } })`,  }];}
```

The context block is registered as `notes_scratchpad` (namespaced by extension name).

## Custom workspace backends

The individual tool factories are exported for use with custom storage backends:

* [  JavaScript ](#tab-panel-5915)
* [  TypeScript ](#tab-panel-5916)

JavaScript

```
import {  createReadTool,  createWriteTool,  createEditTool,  createListTool,  createFindTool,  createGrepTool,  createDeleteTool,  createWorkspaceTools,} from "@cloudflare/think/tools/workspace";
```

TypeScript

```
import {  createReadTool,  createWriteTool,  createEditTool,  createListTool,  createFindTool,  createGrepTool,  createDeleteTool,  createWorkspaceTools,} from "@cloudflare/think/tools/workspace";
```

Implement the operations interface for your storage backend:

* [  JavaScript ](#tab-panel-5917)
* [  TypeScript ](#tab-panel-5918)

JavaScript

```
const myReadOps = {  readFile: async (path) => fetchFromMyStorage(path),  stat: async (path) => getFileInfo(path),};
const readTool = createReadTool({ ops: myReadOps });
```

TypeScript

```
import type { ReadOperations } from "@cloudflare/think/tools/workspace";
const myReadOps: ReadOperations = {  readFile: async (path) => fetchFromMyStorage(path),  stat: async (path) => getFileInfo(path),};
const readTool = createReadTool({ ops: myReadOps });
```

Or create the full set from a `Workspace`, optionally disabling the Bash tool:

* [  JavaScript ](#tab-panel-5919)
* [  TypeScript ](#tab-panel-5920)

JavaScript

```
import { createWorkspaceTools } from "@cloudflare/think/tools/workspace";
const tools = createWorkspaceTools(myCustomWorkspace);const toolsWithoutBash = createWorkspaceTools(myCustomWorkspace, {  bash: false,});
```

TypeScript

```
import { createWorkspaceTools } from "@cloudflare/think/tools/workspace";
const tools = createWorkspaceTools(myCustomWorkspace);const toolsWithoutBash = createWorkspaceTools(myCustomWorkspace, {  bash: false,});
```

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/think/tools/#page","headline":"Tools · Cloudflare Agents docs","description":"Built-in workspace tools (including bash), custom tools, approvals, MCP tools, code execution, browser tools, and extensions for Think agents.","url":"https://developers.cloudflare.com/agents/harnesses/think/tools/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-20","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/harnesses/think/tools/","name":"Tools"}}]}
```

---

---
title: Workflows
description: Run a durable model-driven reasoning step inside a Cloudflare Workflow with ThinkWorkflow and step.prompt(), including structured output and timeouts.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Workflows

`ThinkWorkflow` connects Think to Cloudflare Workflows when a durable job needs one model-driven reasoning step.

Use it when the Workflow owns the process:

* durable multi-step orchestration
* approval gates or long waits
* retryable deterministic side effects
* a Think turn that should produce typed structured output

Keep recurring prompts as [scheduled tasks](https://developers.cloudflare.com/agents/harnesses/think/scheduled-tasks/), and keep simple one-off background turns on [submitMessages()](https://developers.cloudflare.com/agents/harnesses/think/programmatic-submissions/). Workflows are for jobs where the steps matter.

## API

Import from `@cloudflare/think/workflows`:

TypeScript

```
import { ThinkWorkflow } from "@cloudflare/think/workflows";
```

Extend `ThinkWorkflow` and call `step.prompt()` inside `run()`:

* [  JavaScript ](#tab-panel-5927)
* [  TypeScript ](#tab-panel-5928)

JavaScript

```
import { z } from "zod";import { ThinkWorkflow } from "@cloudflare/think/workflows";
const draftSchema = z.object({  title: z.string(),  summary: z.string(),  labels: z.array(z.string()),});
export class TriageWorkflow extends ThinkWorkflow {  async run(event, step) {    const draft = await step.prompt("triage-issue", {      prompt: `Triage issue #${event.payload.issueNumber}`,      output: draftSchema,      timeout: "3 days",    });
    await step.do("apply-labels", async () => {      await this.agent.applyLabels(draft.labels);    });  }}
```

TypeScript

```
import { z } from "zod";import { ThinkWorkflow } from "@cloudflare/think/workflows";import type { ThinkWorkflowStep } from "@cloudflare/think/workflows";import type { AgentWorkflowEvent } from "agents/workflows";
const draftSchema = z.object({  title: z.string(),  summary: z.string(),  labels: z.array(z.string()),});
export class TriageWorkflow extends ThinkWorkflow<TriageAgent, Params> {  async run(event: AgentWorkflowEvent<Params>, step: ThinkWorkflowStep) {    const draft = await step.prompt("triage-issue", {      prompt: `Triage issue #${event.payload.issueNumber}`,      output: draftSchema,      timeout: "3 days",    });
    await step.do("apply-labels", async () => {      await this.agent.applyLabels(draft.labels);    });  }}
```

Start the Workflow from inside your Think Agent with `runWorkflow()`:

* [  JavaScript ](#tab-panel-5925)
* [  TypeScript ](#tab-panel-5926)

JavaScript

```
export class TriageAgent extends Think {  async triageIssue(issueNumber) {    return this.runWorkflow(      "TRIAGE_WORKFLOW",      { issueNumber },      { metadata: { issueNumber } },    );  }}
```

TypeScript

```
export class TriageAgent extends Think<Env> {  async triageIssue(issueNumber: number): Promise<string> {    return this.runWorkflow(      "TRIAGE_WORKFLOW",      { issueNumber },      { metadata: { issueNumber } },    );  }}
```

`runWorkflow()` creates the Workflow instance and injects the Agent identity that `ThinkWorkflow` needs to reconnect to `this.agent` inside `run()`. Prefer it over calling the Workflows binding directly:

TypeScript

```
// Avoid this for Agent workflows. It does not include Agent context.await this.env.TRIAGE_WORKFLOW.create({ params: { issueNumber } });
```

Use `sendWorkflowEvent()` from the Agent when a waiting Workflow needs an external signal, such as human approval:

TypeScript

```
await this.sendWorkflowEvent("TRIAGE_WORKFLOW", workflowId, {  type: "approval",  payload: { approved: true },});
```

`step.prompt()` accepts a prompt string and a Zod object schema. The schema is converted to JSON Schema before the Workflow calls the Agent. Think then runs a full agentic turn: the Agent may use its tools across multiple steps and returns the structured result by calling an internal `final_answer` tool whose arguments match the schema. This uses ordinary tool calling rather than a streaming `response_format`, so it works across every provider Think supports — including Workers AI, which rejects JSON Schema responses on streaming requests. When the Workflow resumes, the payload is validated again with the original Zod schema before the typed value is returned.

Unsupported Zod features that cannot be represented as JSON Schema fail while creating the prompt step. Think does not silently repair invalid model output. If the model does not produce a valid `final_answer` call, the submission reaches a terminal error state and `step.prompt()` throws.

### Behavior notes

* **The Agent may use its tools first.** A `step.prompt()` turn is a full agentic turn: the Agent can call its own tools across multiple steps and then call the final-answer tool. Allow at least `maxSteps: 2` if you expect the Agent to use a tool before answering — with `maxSteps: 1` it is forced to answer on the first step and cannot call any other tool.
* **Tool use is forced during a structured turn.** To guarantee the Agent terminates with a structured answer (rather than replying in plain text), Think sets `toolChoice` for the turn. Do not override `toolChoice` from `beforeTurn` on a `step.prompt()` turn — doing so can prevent the Agent from calling the final-answer tool, which makes the prompt fail.
* **`think_final_answer` is reserved.** Think injects an internal `think_final_answer` tool to carry the structured result. This name (and any `think_final_answer_*` variant) is reserved; its call and result are stripped from the persisted conversation, so the transcript and later turns do not see Think's internal plumbing.
* **The model must support streaming tool calls.** Think streams every turn, so `step.prompt()` works only with models that reliably emit a forced tool call while streaming. Strong tool-callers (for example OpenAI `gpt-4o-mini`, Anthropic `claude-haiku-4-5`, and Workers AI `@cf/moonshotai/kimi-k2.6`) are verified to work. Some models honor a forced `toolChoice` only on non-streaming requests and will reply in plain text and stop while streaming — for example Workers AI `@cf/meta/llama-3.3-70b-instruct-fp8-fast`. With those models the turn ends without a `think_final_answer` call and `step.prompt()` fails (`Model ended the turn without calling the think_final_answer tool`); use a model with working streaming tool calls instead.

## How it runs

The call reads like a blocking step, but it does not hold a long-lived Durable Object RPC open.

1. `step.do("<name>:submit", ...)` creates or finds an idempotent Think submission.
2. Think runs the submitted turn through the normal submission queue.
3. When the submission reaches `completed`, `error`, `aborted`, or `skipped`, Think records a pending workflow notification.
4. Think drains the notification outbox with `sendWorkflowEvent()` and Durable Object alarms until delivery succeeds.
5. `step.waitForEvent("<name>:wait", ...)` resumes the Workflow.
6. `step.prompt()` validates the structured output or throws a typed error.

The machine-readable output is carried in the pending notification and Workflow event payload. Think does not store a separate `output_json` column on the submission ledger, and clears the notification payload after delivery. After delivery, the Workflow owns the durable result.

## Idempotency

By default, `step.prompt()` infers the idempotency key from Workflow identity and step name:

```
think-workflow:<workflowName>:<workflowId>:<stepName>
```

For loops, pass a string `key` to distinguish repeated uses of the same step name:

TypeScript

```
await step.prompt("summarize-file", {  key: file.path,  prompt: `Summarize ${file.path}`,  output: summarySchema,});
```

Prompt text is not part of the inferred key, but Think stores workflow metadata and a prompt/config fingerprint for diagnostics.

## Timeouts

Pass `timeout` to control how long the Workflow waits for the terminal event. If the wait times out, `step.prompt()` cancels the Think submission by default and throws `ThinkPromptTimeoutError`.

Set `cancelOnTimeout: false` when you intentionally want the Think submission to continue after the Workflow stops waiting.

## Boundary with other primitives

Use [getScheduledTasks()](https://developers.cloudflare.com/agents/harnesses/think/scheduled-tasks/) for recurring prompt submissions or deterministic scheduled handlers:

TypeScript

```
getScheduledTasks() {  return {    dailySummary: {      schedule: "every day at 09:00",      timezone: "UTC",      prompt: "Generate the daily report."    },    dailyWorkflow: {      schedule: "every day at 09:00",      timezone: "UTC",      retry: { maxAttempts: 3 },      handler: async ({ idempotencyKey, scheduledFor, timezone }) => {        await this.env.REPORT_WORKFLOW.create({          id: idempotencyKey,          params: { scheduledFor, timezone }        });      }    }  };}
```

Use [submitMessages()](https://developers.cloudflare.com/agents/harnesses/think/programmatic-submissions/) for durable one-off turns where the caller can inspect submission status later.

Use [startFiber()](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/#startfiber) for app-owned idempotent Agent jobs that need recovery inside the Agent. Think's workflow notification delivery does not use fibers; it uses a private outbox because it needs to store an event until delivery succeeds.

Use Workflows when the process has multiple deterministic steps, long waits, or human approval.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/harnesses/think/workflows/#page","headline":"Workflows · Cloudflare Agents docs","description":"Run a durable model-driven reasoning step inside a Cloudflare Workflow with ThinkWorkflow and step.prompt(), including structured output and timeouts.","url":"https://developers.cloudflare.com/agents/harnesses/think/workflows/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-16","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/harnesses/","name":"Harnesses"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/harnesses/think/","name":"Think"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/harnesses/think/workflows/","name":"Workflows"}}]}
```

---

---
title: Model Context Protocol (MCP)
description: Build and deploy remote MCP servers on Cloudflare to connect AI agents with external tools and services.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Model Context Protocol (MCP)

You can build and deploy [Model Context Protocol (MCP) ↗](https://modelcontextprotocol.io/) servers on Cloudflare.

## What is the Model Context Protocol (MCP)?

[Model Context Protocol (MCP) ↗](https://modelcontextprotocol.io) is an open standard that connects AI systems with external applications. Think of MCP like a USB-C port for AI applications. Just as USB-C provides a standardized way to connect your devices to various accessories, MCP provides a standardized way to connect AI agents to different services.

### MCP Terminology

* **MCP Hosts**: AI assistants (like [Claude ↗](https://claude.ai) or [Cursor ↗](https://cursor.com)), AI agents, or applications that need to access external capabilities.
* **MCP Clients**: Clients embedded within the MCP hosts that connect to MCP servers and invoke tools. Each MCP client instance has a single connection to an MCP server.
* **MCP Servers**: Applications that expose [tools](https://developers.cloudflare.com/agents/model-context-protocol/protocol/tools/), [prompts ↗](https://modelcontextprotocol.io/docs/concepts/prompts), and [resources ↗](https://modelcontextprotocol.io/docs/concepts/resources) that MCP clients can use.

### Remote vs. local MCP connections

The MCP standard supports two modes of operation:

* **Remote MCP connections**: MCP clients connect to MCP servers over the Internet, establishing a connection using [Streamable HTTP](https://developers.cloudflare.com/agents/model-context-protocol/protocol/transport/), and authorizing the MCP client access to resources on the user's account using [OAuth](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/).
* **Local MCP connections**: MCP clients connect to MCP servers on the same machine, using [stdio ↗](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) as a local transport method.

### Best Practices

* **Tool design**: Do not treat your MCP server as a wrapper around your full API schema. Instead, build tools that are optimized for specific user goals and reliable outcomes. Fewer, well-designed tools often outperform many granular ones, especially for agents with small context windows or tight latency budgets.
* **Scoped permissions**: Deploying several focused MCP servers, each with narrowly scoped permissions, reduces the risk of over-privileged access and makes it easier to manage and audit what each server is allowed to do.
* **Tool descriptions**: Detailed parameter descriptions help agents understand how to use your tools correctly — including what values are expected, how they affect behavior, and any important constraints. This reduces errors and improves reliability.
* **Evaluation tests**: Use evaluation tests ('evals') to measure the agent’s ability to use your tools correctly. Run these after any updates to your server or tool descriptions to catch regressions early and track improvements over time.

### Get Started

Go to the [Getting Started](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) guide to learn how to build and deploy your first remote MCP server to Cloudflare.

```json
{"@context":"https://schema.org","@type":"WebPage","@id":"https://developers.cloudflare.com/agents/model-context-protocol/#page","headline":"Model Context Protocol (MCP) · Cloudflare Agents docs","description":"Build and deploy remote MCP servers on Cloudflare to connect AI agents with external tools and services.","url":"https://developers.cloudflare.com/agents/model-context-protocol/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}}]}
```

---

---
title: McpAgent
description: Build stateful MCP servers on Cloudflare by extending the McpAgent class with persistent storage and agent capabilities.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# McpAgent

When you build MCP Servers on Cloudflare, you extend the [McpAgent class ↗](https://github.com/cloudflare/agents/blob/main/packages/agents/src/mcp/index.ts#L32-L620), from the Agents SDK:

* [  JavaScript ](#tab-panel-5937)
* [  TypeScript ](#tab-panel-5938)

JavaScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
export class MyMCP extends McpAgent {  server = new McpServer({ name: "Demo", version: "1.0.0" });
  async init() {    this.server.tool(      "add",      { a: z.number(), b: z.number() },      async ({ a, b }) => ({        content: [{ type: "text", text: String(a + b) }],      }),    );  }}
```

TypeScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
export class MyMCP extends McpAgent {  server = new McpServer({ name: "Demo", version: "1.0.0" });
  async init() {    this.server.tool(      "add",      { a: z.number(), b: z.number() },      async ({ a, b }) => ({        content: [{ type: "text", text: String(a + b) }],      }),    );  }}
```

This means that each instance of your MCP server has its own durable state, backed by a [Durable Object](https://developers.cloudflare.com/durable-objects/), with its own [SQL database](https://developers.cloudflare.com/agents/runtime/lifecycle/state/).

Your MCP server doesn't necessarily have to be an Agent. You can build MCP servers that are stateless, and just add [tools](https://developers.cloudflare.com/agents/model-context-protocol/protocol/tools/) to your MCP server using the `@modelcontextprotocol/sdk` package.

But if you want your MCP server to:

* remember previous tool calls, and responses it provided
* provide a game to the MCP client, remembering the state of the game board, previous moves, and the score
* cache the state of a previous external API call, so that subsequent tool calls can reuse it
* do anything that an Agent can do, but allow MCP clients to communicate with it

You can use the APIs below in order to do so.

## API overview

| Property/Method               | Description                                        |
| ----------------------------- | -------------------------------------------------- |
| state                         | Current state object (persisted)                   |
| initialState                  | Default state when instance starts                 |
| setState(state)               | Update and persist state                           |
| onStateChanged(state)         | Called when state changes                          |
| sql                           | Execute SQL queries on embedded database           |
| server                        | The McpServer instance for registering tools       |
| props                         | User identity and tokens from OAuth authentication |
| elicitInput(options, context) | Request structured input from user                 |
| McpAgent.serve(path, options) | Static method to create a Worker handler           |

## Deploying with McpAgent.serve()

The `McpAgent.serve()` static method creates a Worker handler that routes requests to your MCP server:

* [  JavaScript ](#tab-panel-5939)
* [  TypeScript ](#tab-panel-5940)

JavaScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
export class MyMCP extends McpAgent {  server = new McpServer({ name: "my-server", version: "1.0.0" });
  async init() {    this.server.tool("square", { n: z.number() }, async ({ n }) => ({      content: [{ type: "text", text: String(n * n) }],    }));  }}
// Export the Worker handlerexport default MyMCP.serve("/mcp");
```

TypeScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
export class MyMCP extends McpAgent {  server = new McpServer({ name: "my-server", version: "1.0.0" });
  async init() {    this.server.tool("square", { n: z.number() }, async ({ n }) => ({      content: [{ type: "text", text: String(n * n) }],    }));  }}
// Export the Worker handlerexport default MyMCP.serve("/mcp");
```

This is the simplest way to deploy an MCP server — about 15 lines of code. The `serve()` method handles Streamable HTTP transport automatically.

### With OAuth authentication

When using the [OAuth Provider Library ↗](https://github.com/cloudflare/workers-oauth-provider), pass your MCP server to `apiHandlers`:

* [  JavaScript ](#tab-panel-5931)
* [  TypeScript ](#tab-panel-5932)

JavaScript

```
import { OAuthProvider } from "@cloudflare/workers-oauth-provider";
export default new OAuthProvider({  apiHandlers: { "/mcp": MyMCP.serve("/mcp") },  authorizeEndpoint: "/authorize",  tokenEndpoint: "/token",  clientRegistrationEndpoint: "/register",  defaultHandler: AuthHandler,});
```

TypeScript

```
import { OAuthProvider } from "@cloudflare/workers-oauth-provider";
export default new OAuthProvider({  apiHandlers: { "/mcp": MyMCP.serve("/mcp") },  authorizeEndpoint: "/authorize",  tokenEndpoint: "/token",  clientRegistrationEndpoint: "/register",  defaultHandler: AuthHandler,});
```

## Data jurisdiction

For GDPR and data residency compliance, specify a jurisdiction to ensure your MCP server instances run in specific regions:

* [  JavaScript ](#tab-panel-5929)
* [  TypeScript ](#tab-panel-5930)

JavaScript

```
// EU jurisdiction for GDPR complianceexport default MyMCP.serve("/mcp", { jurisdiction: "eu" });
```

TypeScript

```
// EU jurisdiction for GDPR complianceexport default MyMCP.serve("/mcp", { jurisdiction: "eu" });
```

With OAuth:

* [  JavaScript ](#tab-panel-5935)
* [  TypeScript ](#tab-panel-5936)

JavaScript

```
export default new OAuthProvider({  apiHandlers: {    "/mcp": MyMCP.serve("/mcp", { jurisdiction: "eu" }),  },  // ... other OAuth config});
```

TypeScript

```
export default new OAuthProvider({  apiHandlers: {    "/mcp": MyMCP.serve("/mcp", { jurisdiction: "eu" }),  },  // ... other OAuth config});
```

When you specify `jurisdiction: "eu"`:

* All MCP session data stays within the EU
* User data processed by your tools remains in the EU
* State stored in the Durable Object stays in the EU

Available jurisdictions include `"eu"` (European Union) and `"fedramp"` (FedRAMP compliant locations). Refer to [Durable Objects data location](https://developers.cloudflare.com/durable-objects/reference/data-location/) for more options.

## Hibernation support

`McpAgent` instances automatically support [WebSockets Hibernation](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api), allowing stateful MCP servers to sleep during inactive periods while preserving their state. This means your agents only consume compute resources when actively processing requests, optimizing costs while maintaining the full context and conversation history.

Hibernation is enabled by default and requires no additional configuration.

## Stream resumability

`McpAgent`'s Streamable HTTP transport survives the roughly 5-minute Cloudflare edge idle-stream watchdog so in-flight tool calls are not lost on a flaky connection:

* **GET (standalone listen stream)** — when an `EventStore` is configured, idle drops are recovered by clients reconnecting with a `Last-Event-ID` header (no keepalive needed). Without an `EventStore`, a comment-frame keepalive (`: keepalive`, every 25 seconds) keeps long-lived listeners alive.
* **POST (tool response stream)** — always keepalive, so in-flight tool calls survive the idle watchdog. POST streams can additionally be resumed via `Last-Event-ID` when an `EventStore` is configured; a reconnecting client replays any events it missed up to and including the final response. Each POST stream's events are cleared when its close frame is written.

`DurableObjectEventStore` is exported from `agents/mcp` for stateful `WorkerTransport` callers that embed the transport inside an Agent or Durable Object:

* [  JavaScript ](#tab-panel-5933)
* [  TypeScript ](#tab-panel-5934)

JavaScript

```
import { DurableObjectEventStore } from "agents/mcp";
const eventStore = new DurableObjectEventStore(this.ctx.storage);
```

TypeScript

```
import { DurableObjectEventStore } from "agents/mcp";
const eventStore = new DurableObjectEventStore(this.ctx.storage);
```

Refer to [MCP Transport](https://developers.cloudflare.com/agents/model-context-protocol/protocol/transport/) for transport configuration.

## Authentication and authorization

The McpAgent class provides seamless integration with the [OAuth Provider Library ↗](https://github.com/cloudflare/workers-oauth-provider) for [authentication and authorization](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/).

When a user authenticates to your MCP server, their identity information and tokens are made available through the `props` parameter, allowing you to:

* access user-specific data
* check user permissions before performing operations
* customize responses based on user attributes
* use authentication tokens to make requests to external services on behalf of the user

## State synchronization APIs

The `McpAgent` class provides full access to the [Agent state APIs](https://developers.cloudflare.com/agents/runtime/lifecycle/state/):

* [state](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) — Current persisted state
* [initialState](https://developers.cloudflare.com/agents/runtime/lifecycle/state/#set-the-initial-state-for-an-agent) — Default state when instance starts
* [setState](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) — Update and persist state
* [onStateChanged](https://developers.cloudflare.com/agents/runtime/lifecycle/state/#synchronizing-state) — React to state changes
* [sql](https://developers.cloudflare.com/agents/runtime/agents-api/#sql-api) — Execute SQL queries on embedded database

State resets after the session ends

Currently, each client session is backed by an instance of the `McpAgent` class. This is handled automatically for you, as shown in the [getting started guide](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/). This means that when the same client reconnects, they will start a new session, and the state will be reset.

For example, the following code implements an MCP server that remembers a counter value, and updates the counter when the `add` tool is called:

* [  JavaScript ](#tab-panel-5943)
* [  TypeScript ](#tab-panel-5944)

JavaScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
export class MyMCP extends McpAgent {  server = new McpServer({    name: "Demo",    version: "1.0.0",  });
  initialState = {    counter: 1,  };
  async init() {    this.server.resource(`counter`, `mcp://resource/counter`, (uri) => {      return {        contents: [{ uri: uri.href, text: String(this.state.counter) }],      };    });
    this.server.tool(      "add",      "Add to the counter, stored in the MCP",      { a: z.number() },      async ({ a }) => {        this.setState({ ...this.state, counter: this.state.counter + a });
        return {          content: [            {              type: "text",              text: String(`Added ${a}, total is now ${this.state.counter}`),            },          ],        };      },    );  }
  onStateChanged(state) {    console.log({ stateUpdate: state });  }}
```

TypeScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
type State = { counter: number };
export class MyMCP extends McpAgent<Env, State, {}> {  server = new McpServer({    name: "Demo",    version: "1.0.0",  });
  initialState: State = {    counter: 1,  };
  async init() {    this.server.resource(`counter`, `mcp://resource/counter`, (uri) => {      return {        contents: [{ uri: uri.href, text: String(this.state.counter) }],      };    });
    this.server.tool(      "add",      "Add to the counter, stored in the MCP",      { a: z.number() },      async ({ a }) => {        this.setState({ ...this.state, counter: this.state.counter + a });
        return {          content: [            {              type: "text",              text: String(`Added ${a}, total is now ${this.state.counter}`),            },          ],        };      },    );  }
  onStateChanged(state: State) {    console.log({ stateUpdate: state });  }}
```

## Elicitation (human-in-the-loop)

MCP servers can request additional user input during tool execution using **elicitation**. The MCP client (like Claude Desktop) renders a form based on your JSON Schema and returns the user's response.

### When to use elicitation

* Request structured input that was not part of the original tool call
* Confirm high-stakes operations before proceeding
* Gather additional context or preferences mid-execution

### `elicitInput(options, context)`

Request structured input from the user during tool execution.

**Parameters:**

| Parameter                | Type        | Description                                  |
| ------------------------ | ----------- | -------------------------------------------- |
| options.message          | string      | Message explaining what input is needed      |
| options.requestedSchema  | JSON Schema | Schema defining the expected input structure |
| context.relatedRequestId | string      | The extra.requestId from the tool handler    |

**Returns:** `Promise<{ action: "accept" | "decline", content?: object }>`

* [  JavaScript ](#tab-panel-5945)
* [  TypeScript ](#tab-panel-5946)

JavaScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
export class CounterMCP extends McpAgent {  server = new McpServer({    name: "counter-server",    version: "1.0.0",  });
  initialState = { counter: 0 };
  async init() {    this.server.tool(      "increase-counter",      "Increase the counter by a user-specified amount",      { confirm: z.boolean().describe("Do you want to increase the counter?") },      async ({ confirm }, extra) => {        if (!confirm) {          return { content: [{ type: "text", text: "Cancelled." }] };        }
        // Request additional input from the user        const userInput = await this.server.server.elicitInput(          {            message: "By how much do you want to increase the counter?",            requestedSchema: {              type: "object",              properties: {                amount: {                  type: "number",                  title: "Amount",                  description: "The amount to increase the counter by",                },              },              required: ["amount"],            },          },          { relatedRequestId: extra.requestId },        );
        // Check if user accepted or cancelled        if (userInput.action !== "accept" || !userInput.content) {          return { content: [{ type: "text", text: "Cancelled." }] };        }
        // Use the input        const amount = Number(userInput.content.amount);        this.setState({          ...this.state,          counter: this.state.counter + amount,        });
        return {          content: [            {              type: "text",              text: `Counter increased by ${amount}, now at ${this.state.counter}`,            },          ],        };      },    );  }}
```

TypeScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
type State = { counter: number };
export class CounterMCP extends McpAgent<Env, State, {}> {  server = new McpServer({    name: "counter-server",    version: "1.0.0",  });
  initialState: State = { counter: 0 };
  async init() {    this.server.tool(      "increase-counter",      "Increase the counter by a user-specified amount",      { confirm: z.boolean().describe("Do you want to increase the counter?") },      async ({ confirm }, extra) => {        if (!confirm) {          return { content: [{ type: "text", text: "Cancelled." }] };        }
        // Request additional input from the user        const userInput = await this.server.server.elicitInput(          {            message: "By how much do you want to increase the counter?",            requestedSchema: {              type: "object",              properties: {                amount: {                  type: "number",                  title: "Amount",                  description: "The amount to increase the counter by",                },              },              required: ["amount"],            },          },          { relatedRequestId: extra.requestId },        );
        // Check if user accepted or cancelled        if (userInput.action !== "accept" || !userInput.content) {          return { content: [{ type: "text", text: "Cancelled." }] };        }
        // Use the input        const amount = Number(userInput.content.amount);        this.setState({          ...this.state,          counter: this.state.counter + amount,        });
        return {          content: [            {              type: "text",              text: `Counter increased by ${amount}, now at ${this.state.counter}`,            },          ],        };      },    );  }}
```

### JSON Schema for forms

The `requestedSchema` defines the form structure shown to the user:

TypeScript

```
const schema = {  type: "object",  properties: {    // Text input    name: {      type: "string",      title: "Name",      description: "Enter your name",    },    // Number input    amount: {      type: "number",      title: "Amount",      minimum: 1,      maximum: 100,    },    // Boolean (checkbox)    confirm: {      type: "boolean",      title: "I confirm this action",    },    // Enum (dropdown)    priority: {      type: "string",      enum: ["low", "medium", "high"],      title: "Priority",    },  },  required: ["name", "amount"],};
```

### Handling responses

* [  JavaScript ](#tab-panel-5941)
* [  TypeScript ](#tab-panel-5942)

JavaScript

```
const result = await this.server.server.elicitInput(  { message: "Confirm action", requestedSchema: schema },  { relatedRequestId: extra.requestId },);
switch (result.action) {  case "accept":    // User submitted the form    const { name, amount } = result.content;    // Process the input...    break;
  case "decline":    // User cancelled    return { content: [{ type: "text", text: "Operation cancelled." }] };}
```

TypeScript

```
const result = await this.server.server.elicitInput(  { message: "Confirm action", requestedSchema: schema },  { relatedRequestId: extra.requestId },);
switch (result.action) {  case "accept":    // User submitted the form    const { name, amount } = result.content as { name: string; amount: number };    // Process the input...    break;
  case "decline":    // User cancelled    return { content: [{ type: "text", text: "Operation cancelled." }] };}
```

MCP client support

Elicitation requires MCP client support. Not all MCP clients implement the elicitation capability. Check the client documentation for compatibility.

For more human-in-the-loop patterns including workflow-based approval, refer to [Human-in-the-loop patterns](https://developers.cloudflare.com/agents/concepts/agentic-patterns/human-in-the-loop/).

## Next steps

[ Build a Remote MCP server ](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) Get started with MCP servers on Cloudflare. 

[ MCP Tools ](https://developers.cloudflare.com/agents/model-context-protocol/protocol/tools/) Design and add tools to your MCP server. 

[ Authorization ](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/) Set up OAuth authentication. 

[ Securing MCP servers ](https://developers.cloudflare.com/agents/model-context-protocol/guides/securing-mcp-server/) Security best practices for production. 

[ createMcpHandler ](https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/) Build stateless MCP servers.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/#page","headline":"McpAgent · Cloudflare Agents docs","description":"Build stateful MCP servers on Cloudflare by extending the McpAgent class with persistent storage and agent capabilities.","url":"https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-22","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/apis/","name":"APIs"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/apis/agent-api/","name":"McpAgent"}}]}
```

---

---
title: McpClient
description: Connect Agents to external MCP servers to use their tools, resources, and prompts over the Model Context Protocol.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# McpClient

Connect your agent to external [Model Context Protocol (MCP)](https://developers.cloudflare.com/agents/model-context-protocol/) servers to use their tools, resources, and prompts. This enables your agent to interact with GitHub, Slack, databases, and other services through a standardized protocol.

## Overview

The MCP client capability lets your agent:

* **Connect to external MCP servers** \- GitHub, Slack, databases, AI services
* **Use their tools** \- Call functions exposed by MCP servers
* **Access resources** \- Read data from MCP servers
* **Use prompts** \- Leverage pre-built prompt templates

Note

This page covers connecting to MCP servers as a client. To create your own MCP server, refer to [Creating MCP servers](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/).

## Quick start

* [  JavaScript ](#tab-panel-5955)
* [  TypeScript ](#tab-panel-5956)

JavaScript

```
import { Agent } from "agents";
export class MyAgent extends Agent {  async onRequest(request) {    // Add an MCP server    const result = await this.addMcpServer(      "github",      "https://mcp.github.com/mcp",    );
    if (result.state === "authenticating") {      // Server requires OAuth - redirect user to authorize      return Response.redirect(result.authUrl);    }
    // Server is ready - tools are now available    const state = this.getMcpServers();    console.log(`Connected! ${state.tools.length} tools available`);
    return new Response("MCP server connected");  }}
```

TypeScript

```
import { Agent } from "agents";
export class MyAgent extends Agent {  async onRequest(request: Request) {    // Add an MCP server    const result = await this.addMcpServer(      "github",      "https://mcp.github.com/mcp",    );
    if (result.state === "authenticating") {      // Server requires OAuth - redirect user to authorize      return Response.redirect(result.authUrl);    }
    // Server is ready - tools are now available    const state = this.getMcpServers();    console.log(`Connected! ${state.tools.length} tools available`);
    return new Response("MCP server connected");  }}
```

Connections persist in the agent's [SQL storage](https://developers.cloudflare.com/agents/runtime/lifecycle/state/), and when an agent connects to an MCP server, all tools from that server become available automatically.

## Adding MCP servers

Use `addMcpServer()` to connect to an MCP server. For non-OAuth servers, no options are needed:

* [  JavaScript ](#tab-panel-5949)
* [  TypeScript ](#tab-panel-5950)

JavaScript

```
// Non-OAuth server — no options requiredawait this.addMcpServer("notion", "https://mcp.notion.so/mcp");
// OAuth server — callbackHost is auto-derived from the incoming request,// but you can set it explicitly if needed (e.g. custom domains)await this.addMcpServer("github", "https://mcp.github.com/mcp", {  callbackHost: "https://my-worker.workers.dev",});
```

TypeScript

```
// Non-OAuth server — no options requiredawait this.addMcpServer("notion", "https://mcp.notion.so/mcp");
// OAuth server — callbackHost is auto-derived from the incoming request,// but you can set it explicitly if needed (e.g. custom domains)await this.addMcpServer("github", "https://mcp.github.com/mcp", {  callbackHost: "https://my-worker.workers.dev",});
```

### Stable server IDs

By default, each connection is assigned a generated `nanoid(8)` ID. Pass `id` for connector-style integrations so tools surface as readable keys instead of opaque connection IDs.

* [  JavaScript ](#tab-panel-5947)
* [  TypeScript ](#tab-panel-5948)

JavaScript

```
await this.addMcpServer("GitHub", env.MCP_SESSION, {  id: "github",  props: { token: "..." },});// tools surface as `tool_github_<name>`
```

TypeScript

```
await this.addMcpServer("GitHub", env.MCP_SESSION, {  id: "github",  props: { token: "..." },});// tools surface as `tool_github_<name>`
```

When provided, this `id` replaces the generated value as the server's ID in storage, restore, `listServers()`, `listTools()`, `getAITools()`, and OAuth state. The supplied ID is normalized via the exported `normalizeServerId` helper, so values like `"GitHub MCP!"` become `"github-mcp"` — guaranteeing the ID is safe to embed in AI SDK tool names and storage keys.

Stable IDs are fully additive — no existing code breaks. If you add `{ id: "github" }` to an `addMcpServer` call for a server already registered under an auto-generated ID, the SDK transparently migrates the existing storage row, in-memory connection, and OAuth-related storage keys to the new stable ID. No `removeMcpServer` step is required. `addMcpServer` only throws on a genuinely ambiguous collision: the same stable ID already belongs to a _different_ `(name, url)` server.

### Transport options

MCP supports multiple transport types:

* [  JavaScript ](#tab-panel-5951)
* [  TypeScript ](#tab-panel-5952)

JavaScript

```
await this.addMcpServer("server", "https://mcp.example.com/mcp", {  transport: {    type: "streamable-http",  },});
```

TypeScript

```
await this.addMcpServer("server", "https://mcp.example.com/mcp", {  transport: {    type: "streamable-http",  },});
```

| Transport       | Description                                         |
| --------------- | --------------------------------------------------- |
| auto            | Auto-detect based on server response (default)      |
| streamable-http | HTTP with streaming                                 |
| sse             | Server-Sent Events - legacy/compatibility transport |

### Custom headers

For servers behind authentication (like Cloudflare Access) or using bearer tokens:

* [  JavaScript ](#tab-panel-5953)
* [  TypeScript ](#tab-panel-5954)

JavaScript

```
await this.addMcpServer("internal", "https://internal-mcp.example.com/mcp", {  transport: {    headers: {      Authorization: "Bearer my-token",      "CF-Access-Client-Id": "...",      "CF-Access-Client-Secret": "...",    },  },});
```

TypeScript

```
await this.addMcpServer("internal", "https://internal-mcp.example.com/mcp", {  transport: {    headers: {      Authorization: "Bearer my-token",      "CF-Access-Client-Id": "...",      "CF-Access-Client-Secret": "...",    },  },});
```

### URL security

MCP server URLs are validated before connection to prevent Server-Side Request Forgery (SSRF). The following URL targets are blocked:

* Private/internal IP ranges (RFC 1918: `10.x`, `172.16-31.x`, `192.168.x`)
* Unspecified addresses (`0.0.0.0`, `[::]`)
* Link-local addresses (`169.254.x`, `fe80::`)
* IPv6 unique-local addresses (`fc00::/7`)
* IPv4-mapped IPv6 addresses that resolve to private ranges (for example, `[::ffff:10.0.0.1]`)
* Cloud metadata endpoints (`metadata.google.internal`)

Loopback addresses (`localhost`, `127.x.x.x`, `[::1]`) are **allowed** for local development.

For production connections to internal services, use the [RPC transport](https://developers.cloudflare.com/agents/model-context-protocol/protocol/transport/) with a Durable Object binding instead of HTTP.

### Return value

`addMcpServer()` returns the connection state:

* `ready` \- Server connected and tools discovered
* `authenticating` \- Server requires OAuth; redirect user to `authUrl`

## OAuth authentication

Many MCP servers require OAuth authentication. The agent handles the OAuth flow automatically.

### How it works

sequenceDiagram
    participant Client
    participant Agent
    participant MCPServer

    Client->>Agent: addMcpServer(name, url)
    Agent->>MCPServer: Connect
    MCPServer-->>Agent: Requires OAuth
    Agent-->>Client: state: authenticating, authUrl
    Client->>MCPServer: User authorizes
    MCPServer->>Agent: Callback with code
    Agent->>MCPServer: Exchange for token
    Agent-->>Client: onMcpUpdate (ready)

### Handling OAuth in your agent

* [  JavaScript ](#tab-panel-5957)
* [  TypeScript ](#tab-panel-5958)

JavaScript

```
class MyAgent extends Agent {  async onRequest(request) {    const result = await this.addMcpServer(      "github",      "https://mcp.github.com/mcp",    );
    if (result.state === "authenticating") {      // Redirect the user to the OAuth authorization page      return Response.redirect(result.authUrl);    }
    return Response.json({ status: "connected", id: result.id });  }}
```

TypeScript

```
class MyAgent extends Agent {  async onRequest(request: Request) {    const result = await this.addMcpServer(      "github",      "https://mcp.github.com/mcp",    );
    if (result.state === "authenticating") {      // Redirect the user to the OAuth authorization page      return Response.redirect(result.authUrl);    }
    return Response.json({ status: "connected", id: result.id });  }}
```

### OAuth callback

The callback URL is automatically constructed:

```
https://{host}/{agentsPrefix}/{agent-name}/{instance-name}/callback
```

For example: `https://my-worker.workers.dev/agents/my-agent/default/callback`

OAuth tokens are securely stored in SQLite, and persist across agent restarts.

### Protecting instance names in OAuth callbacks

When using `sendIdentityOnConnect: false` to hide sensitive instance names (like session IDs or user IDs), the default OAuth callback URL would expose the instance name. To prevent this security issue, you must provide a custom `callbackPath`.

* [  JavaScript ](#tab-panel-5981)
* [  TypeScript ](#tab-panel-5982)

JavaScript

```
import { Agent, routeAgentRequest, getAgentByName } from "agents";
export class SecureAgent extends Agent {  static options = { sendIdentityOnConnect: false };
  async onRequest(request) {    // callbackPath is required when sendIdentityOnConnect is false    const result = await this.addMcpServer(      "github",      "https://mcp.github.com/mcp",      {        callbackPath: "mcp-oauth-callback", // Custom path without instance name      },    );
    if (result.state === "authenticating") {      return Response.redirect(result.authUrl);    }
    return new Response("Connected!");  }}
// Route the custom callback path to the agentexport default {  async fetch(request, env) {    const url = new URL(request.url);
    // Route custom MCP OAuth callback to agent instance    if (url.pathname.startsWith("/mcp-oauth-callback")) {      // Implement this to extract the instance name from your session/auth mechanism      const instanceName = await getInstanceNameFromSession(request);
      const agent = await getAgentByName(env.SecureAgent, instanceName);      return agent.fetch(request);    }
    // Standard agent routing    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
import { Agent, routeAgentRequest, getAgentByName } from "agents";
export class SecureAgent extends Agent {  static options = { sendIdentityOnConnect: false };
  async onRequest(request: Request) {    // callbackPath is required when sendIdentityOnConnect is false    const result = await this.addMcpServer(      "github",      "https://mcp.github.com/mcp",      {        callbackPath: "mcp-oauth-callback", // Custom path without instance name      },    );
    if (result.state === "authenticating") {      return Response.redirect(result.authUrl);    }
    return new Response("Connected!");  }}
// Route the custom callback path to the agentexport default {  async fetch(request: Request, env: Env) {    const url = new URL(request.url);
    // Route custom MCP OAuth callback to agent instance    if (url.pathname.startsWith("/mcp-oauth-callback")) {      // Implement this to extract the instance name from your session/auth mechanism      const instanceName = await getInstanceNameFromSession(request);
      const agent = await getAgentByName(env.SecureAgent, instanceName);      return agent.fetch(request);    }
    // Standard agent routing    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

How callback matching works

OAuth callbacks are matched by the `state` query parameter (format: `{serverId}:{stateValue}`), not by URL path. This means your custom `callbackPath` can be any path you choose, as long as requests to that path are routed to the correct agent instance.

### Custom OAuth callback handling

Configure how OAuth completion is handled. By default, successful authentication redirects to your application origin, while failed authentication displays an HTML error page.

* [  JavaScript ](#tab-panel-5969)
* [  TypeScript ](#tab-panel-5970)

JavaScript

```
export class MyAgent extends Agent {  onStart() {    this.mcp.configureOAuthCallback({      // Redirect after successful auth      successRedirect: "https://myapp.com/success",
      // Redirect on error with error message in query string      errorRedirect: "https://myapp.com/error",
      // Or use a custom handler      customHandler: () => {        // Close popup window after auth completes        return new Response("<script>window.close();</script>", {          headers: { "content-type": "text/html" },        });      },    });  }}
```

TypeScript

```
export class MyAgent extends Agent {  onStart() {    this.mcp.configureOAuthCallback({      // Redirect after successful auth      successRedirect: "https://myapp.com/success",
      // Redirect on error with error message in query string      errorRedirect: "https://myapp.com/error",
      // Or use a custom handler      customHandler: () => {        // Close popup window after auth completes        return new Response("<script>window.close();</script>", {          headers: { "content-type": "text/html" },        });      },    });  }}
```

## Using MCP capabilities

Once connected, access the server's capabilities:

### Getting available tools

* [  JavaScript ](#tab-panel-5959)
* [  TypeScript ](#tab-panel-5960)

JavaScript

```
const state = this.getMcpServers();
// All tools from all connected serversfor (const tool of state.tools) {  console.log(`Tool: ${tool.name}`);  console.log(`  From server: ${tool.serverId}`);  console.log(`  Title: ${tool.title ?? tool.name}`);  console.log(`  Description: ${tool.description}`);}
```

TypeScript

```
const state = this.getMcpServers();
// All tools from all connected serversfor (const tool of state.tools) {  console.log(`Tool: ${tool.name}`);  console.log(`  From server: ${tool.serverId}`);  console.log(`  Title: ${tool.title ?? tool.name}`);  console.log(`  Description: ${tool.description}`);}
```

### Resources and prompts

* [  JavaScript ](#tab-panel-5965)
* [  TypeScript ](#tab-panel-5966)

JavaScript

```
const state = this.getMcpServers();
// Available resourcesfor (const resource of state.resources) {  console.log(`Resource: ${resource.name} (${resource.uri})`);}
// Available promptsfor (const prompt of state.prompts) {  console.log(`Prompt: ${prompt.name}`);}
```

TypeScript

```
const state = this.getMcpServers();
// Available resourcesfor (const resource of state.resources) {  console.log(`Resource: ${resource.name} (${resource.uri})`);}
// Available promptsfor (const prompt of state.prompts) {  console.log(`Prompt: ${prompt.name}`);}
```

### Server status

* [  JavaScript ](#tab-panel-5963)
* [  TypeScript ](#tab-panel-5964)

JavaScript

```
const state = this.getMcpServers();
for (const [id, server] of Object.entries(state.servers)) {  console.log(`${server.name}: ${server.state}`);  // state: "ready" | "authenticating" | "connecting" | "connected" | "discovering" | "failed"}
```

TypeScript

```
const state = this.getMcpServers();
for (const [id, server] of Object.entries(state.servers)) {  console.log(`${server.name}: ${server.state}`);  // state: "ready" | "authenticating" | "connecting" | "connected" | "discovering" | "failed"}
```

### Integration with AI SDK

To use MCP tools with the AI SDK, use `this.mcp.getAITools()` which converts MCP tools to AI SDK format:

* [  JavaScript ](#tab-panel-5971)
* [  TypeScript ](#tab-panel-5972)

JavaScript

```
import { generateText } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class MyAgent extends Agent {  async onRequest(request) {    const workersai = createWorkersAI({ binding: this.env.AI });    const response = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: "What's the weather in San Francisco?",      tools: this.mcp.getAITools(),    });
    return new Response(response.text);  }}
```

TypeScript

```
import { generateText } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class MyAgent extends Agent<Env> {  async onRequest(request: Request) {    const workersai = createWorkersAI({ binding: this.env.AI });    const response = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: "What's the weather in San Francisco?",      tools: this.mcp.getAITools(),    });
    return new Response(response.text);  }}
```

Note

`getMcpServers().tools` returns raw MCP `Tool` objects for inspection. Use `this.mcp.getAITools()` when passing tools to the AI SDK.

## Managing servers

### Removing a server

* [  JavaScript ](#tab-panel-5961)
* [  TypeScript ](#tab-panel-5962)

JavaScript

```
await this.removeMcpServer(serverId);
```

TypeScript

```
await this.removeMcpServer(serverId);
```

This disconnects from the server and removes it from storage.

### Persistence

MCP servers persist across agent restarts:

* Server configuration stored in SQLite
* OAuth tokens stored securely
* Connections restored automatically when agent wakes

### Listing all servers

* [  JavaScript ](#tab-panel-5967)
* [  TypeScript ](#tab-panel-5968)

JavaScript

```
const state = this.getMcpServers();
for (const [id, server] of Object.entries(state.servers)) {  console.log(`${id}: ${server.name} (${server.server_url})`);}
```

TypeScript

```
const state = this.getMcpServers();
for (const [id, server] of Object.entries(state.servers)) {  console.log(`${id}: ${server.name} (${server.server_url})`);}
```

## Client-side integration

Connected clients receive real-time MCP updates via WebSocket:

* [  JavaScript ](#tab-panel-5987)
* [  TypeScript ](#tab-panel-5988)

JavaScript

```
import { useAgent } from "agents/react";import { useState } from "react";
function Dashboard() {  const [tools, setTools] = useState([]);  const [servers, setServers] = useState({});
  const agent = useAgent({    agent: "MyAgent",    onMcpUpdate: (mcpState) => {      setTools(mcpState.tools);      setServers(mcpState.servers);    },  });
  return (    <div>      <h2>Connected Servers</h2>      {Object.entries(servers).map(([id, server]) => (        <div key={id}>          {server.name}: {server.state}        </div>      ))}
      <h2>Available Tools ({tools.length})</h2>      {tools.map((tool) => (        <div key={`${tool.serverId}-${tool.name}`}>{tool.name}</div>      ))}    </div>  );}
```

TypeScript

```
import { useAgent } from "agents/react";import { useState } from "react";
function Dashboard() {  const [tools, setTools] = useState([]);  const [servers, setServers] = useState({});
  const agent = useAgent({    agent: "MyAgent",    onMcpUpdate: (mcpState) => {      setTools(mcpState.tools);      setServers(mcpState.servers);    },  });
  return (    <div>      <h2>Connected Servers</h2>      {Object.entries(servers).map(([id, server]) => (        <div key={id}>          {server.name}: {server.state}        </div>      ))}
      <h2>Available Tools ({tools.length})</h2>      {tools.map((tool) => (        <div key={`${tool.serverId}-${tool.name}`}>{tool.name}</div>      ))}    </div>  );}
```

## API reference

### `addMcpServer()`

Add a connection to an MCP server and make its tools available to your agent.

Calling `addMcpServer` is idempotent when both the server name **and** URL match an existing active connection — the existing connection is returned without creating a duplicate. This makes it safe to call in `onStart()` without worrying about duplicate connections on restart.

If you call `addMcpServer` with the same name but a **different** URL, a new connection is created. Both connections remain active and their tools are merged in `getAITools()`. To replace a server, call `removeMcpServer(oldId)` first.

URLs are normalized before comparison (trailing slashes, default ports, and hostname case are handled), so `https://MCP.Example.com` and `https://mcp.example.com/` are treated as the same URL.

TypeScript

```
// HTTP transport (Streamable HTTP, SSE)async addMcpServer(  serverName: string,  url: string,  options?: {    id?: string;    callbackHost?: string;    callbackPath?: string;    agentsPrefix?: string;    client?: ClientOptions;    transport?: {      headers?: HeadersInit;      type?: "sse" | "streamable-http" | "auto";    };    retry?: RetryOptions;  }): Promise<  | { id: string; state: "authenticating"; authUrl: string }  | { id: string; state: "ready" }>
// RPC transport (Durable Object binding — no HTTP overhead)async addMcpServer(  serverName: string,  binding: DurableObjectNamespace,  options?: {    id?: string;    props?: Record<string, unknown>;    client?: ClientOptions;    retry?: RetryOptions;  }): Promise<{ id: string; state: "ready" }>
```

#### Parameters (HTTP transport)

* `serverName` (string, required) — Display name for the MCP server
* `url` (string, required) — URL of the MCP server endpoint
* `options` (object, optional) — Connection configuration:  
  * `id` — Optional stable, caller-supplied server ID for connector-style integrations. When provided, it replaces the generated `nanoid(8)` across storage, `listServers()`, `listTools()`, `getAITools()` (so tool keys become readable, for example `tool_github_create_pull_request`), and OAuth state. Refer to [Stable server IDs](#stable-server-ids)
  * `callbackHost` — Host for OAuth callback URL. Only needed for OAuth-authenticated servers. If omitted, automatically derived from the incoming request or WebSocket connection URI — you typically do not need to set this unless you are using a custom domain that differs from the Worker's hostname
  * `callbackPath` — Custom callback URL path that bypasses the default `/agents/{class}/{name}/callback` construction. **Required when `sendIdentityOnConnect` is `false`** to prevent leaking the instance name. When set, the callback URL becomes `{callbackHost}/{callbackPath}`. You must route this path to the agent instance via `getAgentByName`
  * `agentsPrefix` — URL prefix for OAuth callback path. Default: `"agents"`. Ignored when `callbackPath` is provided
  * `client` — MCP client configuration options (passed to `@modelcontextprotocol/sdk` Client constructor). By default, includes `CfWorkerJsonSchemaValidator` for validating tool parameters against JSON schemas
  * `transport` — Transport layer configuration:  
    * `headers` — Custom HTTP headers for authentication
    * `type` — Transport type: `"auto"` (default), `"streamable-http"`, or `"sse"`
  * `retry` — Retry options for connection and reconnection attempts. Persisted and used when restoring connections after hibernation or after OAuth completion. Default: 3 attempts, 500ms base delay, 5s max delay. Refer to [Retries](https://developers.cloudflare.com/agents/runtime/execution/retries/) for details on `RetryOptions`.

#### Parameters (RPC transport)

* `serverName` (string, required) — Display name for the MCP server
* `binding` (`DurableObjectNamespace`, required) — The Durable Object binding for the `McpAgent` class
* `options` (object, optional) — Connection configuration:  
  * `id` — Optional stable, caller-supplied server ID. Refer to [Stable server IDs](#stable-server-ids)
  * `props` — Initialization data passed to the `McpAgent`'s `onStart(props)`. Use this to pass user context, configuration, or other data to the MCP server instance
  * `client` — MCP client configuration options
  * `retry` — Retry options for the connection

RPC transport connects your Agent directly to an `McpAgent` via Durable Object bindings without HTTP overhead. Refer to [MCP Transport](https://developers.cloudflare.com/agents/model-context-protocol/protocol/transport/) for details on configuring RPC transport.

#### Returns

A Promise that resolves to a discriminated union based on connection state:

* When `state` is `"authenticating"`:

  * `id` (string) — Unique identifier for this server connection
  * `state` (`"authenticating"`) — Server is waiting for OAuth authorization
  * `authUrl` (string) — OAuth authorization URL for user authentication
* When `state` is `"ready"`:

  * `id` (string) — Unique identifier for this server connection
  * `state` (`"ready"`) — Server is fully connected and operational

### `removeMcpServer()`

Disconnect from an MCP server and clean up its resources.

TypeScript

```
async removeMcpServer(id: string): Promise<void>
```

#### Parameters

* `id` (string, required) — Server connection ID returned from `addMcpServer()`

### `getMcpServers()`

Get the current state of all MCP server connections.

TypeScript

```
getMcpServers(): MCPServersState
```

#### Returns

TypeScript

```
type MCPServersState = {  servers: Record<    string,    {      name: string;      server_url: string;      auth_url: string | null;      state:        | "authenticating"        | "connecting"        | "connected"        | "discovering"        | "ready"        | "failed";      capabilities: ServerCapabilities | null;      instructions: string | null;      error: string | null;    }  >;  tools: Array<Tool & { serverId: string }>;  prompts: Array<Prompt & { serverId: string }>;  resources: Array<Resource & { serverId: string }>;  resourceTemplates: Array<ResourceTemplate & { serverId: string }>;};
```

The `state` field indicates the connection lifecycle:

* `authenticating` — Waiting for OAuth authorization to complete
* `connecting` — Establishing transport connection
* `connected` — Transport connection established
* `discovering` — Discovering server capabilities (tools, resources, prompts)
* `ready` — Fully connected and operational
* `failed` — Connection failed (see `error` field for details)

The `error` field contains an error message when `state` is `"failed"`. Error messages from external OAuth providers are automatically escaped to prevent XSS attacks, making them safe to display directly in your UI.

### `configureOAuthCallback()`

Configure OAuth callback behavior for MCP servers requiring authentication. This method allows you to customize what happens after a user completes OAuth authorization.

TypeScript

```
this.mcp.configureOAuthCallback(options: {  successRedirect?: string;  errorRedirect?: string;  customHandler?: () => Response | Promise<Response>;}): void
```

#### Parameters

* `options` (object, required) — OAuth callback configuration:  
  * `successRedirect` (string, optional) — URL to redirect to after successful authentication
  * `errorRedirect` (string, optional) — URL to redirect to after failed authentication. Error message is appended as `?error=<message>` query parameter
  * `customHandler` (function, optional) — Custom handler for complete control over the callback response. Must return a Response

#### Default behavior

When no configuration is provided:

* **Success**: Redirects to your application origin
* **Failure**: Displays an HTML error page with the error message

If OAuth fails, the connection state becomes `"failed"` and the error message is stored in the `server.error` field for display in your UI.

#### Usage

Configure in `onStart()` before any OAuth flows begin:

* [  JavaScript ](#tab-panel-5977)
* [  TypeScript ](#tab-panel-5978)

JavaScript

```
export class MyAgent extends Agent {  onStart() {    // Option 1: Simple redirects    this.mcp.configureOAuthCallback({      successRedirect: "/dashboard",      errorRedirect: "/auth-error",    });
    // Option 2: Custom handler (e.g., for popup windows)    this.mcp.configureOAuthCallback({      customHandler: () => {        return new Response("<script>window.close();</script>", {          headers: { "content-type": "text/html" },        });      },    });  }}
```

TypeScript

```
export class MyAgent extends Agent {  onStart() {    // Option 1: Simple redirects    this.mcp.configureOAuthCallback({      successRedirect: "/dashboard",      errorRedirect: "/auth-error",    });
    // Option 2: Custom handler (e.g., for popup windows)    this.mcp.configureOAuthCallback({      customHandler: () => {        return new Response("<script>window.close();</script>", {          headers: { "content-type": "text/html" },        });      },    });  }}
```

## Custom OAuth provider

Override the default OAuth provider used when connecting to MCP servers by implementing `createMcpOAuthProvider()` on your Agent class. This enables custom authentication strategies such as pre-registered client credentials or mTLS, beyond the built-in dynamic client registration.

The override is used for both new connections (`addMcpServer`) and restored connections after a Durable Object restart.

* [  JavaScript ](#tab-panel-5983)
* [  TypeScript ](#tab-panel-5984)

JavaScript

```
import { Agent } from "agents";
export class MyAgent extends Agent {  createMcpOAuthProvider(callbackUrl) {    const env = this.env;    return {      get redirectUrl() {        return callbackUrl;      },      get clientMetadata() {        return {          client_id: env.MCP_CLIENT_ID,          client_secret: env.MCP_CLIENT_SECRET,          redirect_uris: [callbackUrl],        };      },      clientInformation() {        return {          client_id: env.MCP_CLIENT_ID,          client_secret: env.MCP_CLIENT_SECRET,        };      },    };  }}
```

TypeScript

```
import { Agent } from "agents";import type { AgentMcpOAuthProvider } from "agents";
export class MyAgent extends Agent<Env> {  createMcpOAuthProvider(callbackUrl: string): AgentMcpOAuthProvider {    const env = this.env;    return {      get redirectUrl() {        return callbackUrl;      },      get clientMetadata() {        return {          client_id: env.MCP_CLIENT_ID,          client_secret: env.MCP_CLIENT_SECRET,          redirect_uris: [callbackUrl],        };      },      clientInformation() {        return {          client_id: env.MCP_CLIENT_ID,          client_secret: env.MCP_CLIENT_SECRET,        };      },    };  }}
```

If you do not override this method, the agent uses the default provider which performs [OAuth 2.0 Dynamic Client Registration ↗](https://datatracker.ietf.org/doc/html/rfc7591) with the MCP server.

### Custom storage backend

To keep the built-in OAuth logic (CSRF state, PKCE, nonce generation, token management) but route token storage to a different backend, import `DurableObjectOAuthClientProvider` and pass your own storage adapter:

* [  JavaScript ](#tab-panel-5973)
* [  TypeScript ](#tab-panel-5974)

JavaScript

```
import { Agent, DurableObjectOAuthClientProvider } from "agents";
export class MyAgent extends Agent {  createMcpOAuthProvider(callbackUrl) {    return new DurableObjectOAuthClientProvider(      myCustomStorage, // any DurableObjectStorage-compatible adapter      this.name,      callbackUrl,    );  }}
```

TypeScript

```
import { Agent, DurableObjectOAuthClientProvider } from "agents";import type { AgentMcpOAuthProvider } from "agents";
export class MyAgent extends Agent {  createMcpOAuthProvider(callbackUrl: string): AgentMcpOAuthProvider {    return new DurableObjectOAuthClientProvider(      myCustomStorage, // any DurableObjectStorage-compatible adapter      this.name,      callbackUrl,    );  }}
```

## Advanced: MCPClientManager

For fine-grained control, use `this.mcp` directly:

### Step-by-step connection

* [  JavaScript ](#tab-panel-5989)
* [  TypeScript ](#tab-panel-5990)

JavaScript

```
// 1. Register the server (saves to storage and creates in-memory connection)const id = "my-server";await this.mcp.registerServer(id, {  url: "https://mcp.example.com/mcp",  name: "My Server",  callbackUrl: "https://my-worker.workers.dev/agents/my-agent/default/callback",  transport: { type: "auto" },});
// 2. Connect (initializes transport, handles OAuth if needed)const connectResult = await this.mcp.connectToServer(id);
if (connectResult.state === "failed") {  console.error("Connection failed:", connectResult.error);  return;}
if (connectResult.state === "authenticating") {  console.log("OAuth required:", connectResult.authUrl);  return;}
// 3. Discover capabilities (transitions from "connected" to "ready")if (connectResult.state === "connected") {  const discoverResult = await this.mcp.discoverIfConnected(id);
  if (!discoverResult?.success) {    console.error("Discovery failed:", discoverResult?.error);  }}
```

TypeScript

```
// 1. Register the server (saves to storage and creates in-memory connection)const id = "my-server";await this.mcp.registerServer(id, {  url: "https://mcp.example.com/mcp",  name: "My Server",  callbackUrl: "https://my-worker.workers.dev/agents/my-agent/default/callback",  transport: { type: "auto" },});
// 2. Connect (initializes transport, handles OAuth if needed)const connectResult = await this.mcp.connectToServer(id);
if (connectResult.state === "failed") {  console.error("Connection failed:", connectResult.error);  return;}
if (connectResult.state === "authenticating") {  console.log("OAuth required:", connectResult.authUrl);  return;}
// 3. Discover capabilities (transitions from "connected" to "ready")if (connectResult.state === "connected") {  const discoverResult = await this.mcp.discoverIfConnected(id);
  if (!discoverResult?.success) {    console.error("Discovery failed:", discoverResult?.error);  }}
```

### Event subscription

* [  JavaScript ](#tab-panel-5975)
* [  TypeScript ](#tab-panel-5976)

JavaScript

```
// Listen for state changes (onServerStateChanged is an Event<void>)const disposable = this.mcp.onServerStateChanged(() => {  console.log("MCP server state changed");  this.broadcastMcpServers(); // Notify connected clients});
// Clean up the subscription when no longer needed// disposable.dispose();
```

TypeScript

```
// Listen for state changes (onServerStateChanged is an Event<void>)const disposable = this.mcp.onServerStateChanged(() => {  console.log("MCP server state changed");  this.broadcastMcpServers(); // Notify connected clients});
// Clean up the subscription when no longer needed// disposable.dispose();
```

Note

MCP server list broadcasts (`cf_agent_mcp_servers`) are automatically filtered to exclude connections where [shouldSendProtocolMessages](https://developers.cloudflare.com/agents/runtime/communication/protocol-messages/) returned `false`.

### Lifecycle methods

#### `this.mcp.registerServer()`

Register a server without immediately connecting.

TypeScript

```
async registerServer(  id: string,  options: {    url: string;    name: string;    callbackUrl: string;    clientOptions?: ClientOptions;    transportOptions?: TransportOptions;  }): Promise<string>
```

#### `this.mcp.connectToServer()`

Establish a connection to a previously registered server.

TypeScript

```
async connectToServer(id: string): Promise<MCPConnectionResult>
type MCPConnectionResult =  | { state: "failed"; error: string }  | { state: "authenticating"; authUrl: string }  | { state: "connected" }
```

#### `this.mcp.discoverIfConnected()`

Check server capabilities if a connection is active.

TypeScript

```
async discoverIfConnected(  serverId: string,  options?: { timeoutMs?: number }): Promise<MCPDiscoverResult | undefined>
type MCPDiscoverResult = {  success: boolean;  state: MCPConnectionState;  error?: string;}
```

#### `this.mcp.waitForConnections()`

Wait for all in-flight MCP connection and discovery operations to settle. This is useful when you need `this.mcp.getAITools()` to return the full set of tools immediately after the agent wakes from hibernation.

TypeScript

```
// Wait indefinitelyawait this.mcp.waitForConnections();
// Wait with a timeout (milliseconds)await this.mcp.waitForConnections({ timeout: 10_000 });
```

Note

`AIChatAgent` calls this automatically via its [waitForMcpConnections](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/#waitformcpconnections) property (defaults to `{ timeout: 10_000 }`). You only need `waitForConnections()` directly when using `Agent` with MCP, or when you want finer control inside `onChatMessage`.

#### `this.mcp.closeConnection()`

Close the connection to a specific server while keeping it registered.

TypeScript

```
async closeConnection(id: string): Promise<void>
```

#### `this.mcp.closeAllConnections()`

Close all active server connections while preserving registrations.

TypeScript

```
async closeAllConnections(): Promise<void>
```

#### `this.mcp.getAITools()`

Get all discovered MCP tools in a format compatible with the AI SDK.

TypeScript

```
getAITools(filter?: MCPServerFilter): ToolSet
```

Tools are automatically namespaced by server ID to prevent conflicts when multiple MCP servers expose tools with the same name.

Pass an `MCPServerFilter` to scope the returned tools to a subset of connected servers:

* [  JavaScript ](#tab-panel-5979)
* [  TypeScript ](#tab-panel-5980)

JavaScript

```
// Tools from a specific server onlyconst githubTools = this.mcp.getAITools({ serverId: "github" });
// Tools from multiple serversconst tools = this.mcp.getAITools({ serverId: ["github", "notion"] });
// Tools from servers matching a nameconst tools = this.mcp.getAITools({ serverName: "GitHub" });
// Only tools from servers that are readyconst tools = this.mcp.getAITools({ state: "ready" });
```

TypeScript

```
// Tools from a specific server onlyconst githubTools = this.mcp.getAITools({ serverId: "github" });
// Tools from multiple serversconst tools = this.mcp.getAITools({ serverId: ["github", "notion"] });
// Tools from servers matching a nameconst tools = this.mcp.getAITools({ serverName: "GitHub" });
// Only tools from servers that are readyconst tools = this.mcp.getAITools({ state: "ready" });
```

The filter type is available from `agents/mcp/client`:

TypeScript

```
import type { MCPServerFilter } from "agents/mcp/client";
type MCPServerFilter = {  serverId?: string | string[];  serverName?: string | string[];  state?: MCPConnectionState | MCPConnectionState[];};
```

All specified filter criteria are AND'd together. The same filter parameter is accepted by `listTools()`, `listPrompts()`, `listResources()`, and `listResourceTemplates()`.

## Error handling

Use error detection utilities to handle connection errors:

* [  JavaScript ](#tab-panel-5985)
* [  TypeScript ](#tab-panel-5986)

JavaScript

```
import { isUnauthorized, isTransportNotImplemented } from "agents";
export class MyAgent extends Agent {  async onRequest(request) {    try {      await this.addMcpServer("Server", "https://mcp.example.com/mcp");    } catch (error) {      if (isUnauthorized(error)) {        return new Response("Authentication required", { status: 401 });      } else if (isTransportNotImplemented(error)) {        return new Response("Transport not supported", { status: 400 });      }      throw error;    }  }}
```

TypeScript

```
import { isUnauthorized, isTransportNotImplemented } from "agents";
export class MyAgent extends Agent {  async onRequest(request: Request) {    try {      await this.addMcpServer("Server", "https://mcp.example.com/mcp");    } catch (error) {      if (isUnauthorized(error)) {        return new Response("Authentication required", { status: 401 });      } else if (isTransportNotImplemented(error)) {        return new Response("Transport not supported", { status: 400 });      }      throw error;    }  }}
```

## Next steps

[ Creating MCP servers ](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/) Build your own MCP server. 

[ Client SDK ](https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/) Connect from browsers with onMcpUpdate. 

[ Store and sync state ](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) Learn about agent persistence.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/#page","headline":"McpClient · Cloudflare Agents docs","description":"Connect Agents to external MCP servers to use their tools, resources, and prompts over the Model Context Protocol.","url":"https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/apis/","name":"APIs"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/apis/client-api/","name":"McpClient"}}]}
```

---

---
title: createMcpHandler
description: Create a stateless MCP server fetch handler for a plain Worker using createMcpHandler and streamable HTTP transport.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# createMcpHandler

The `createMcpHandler` function creates a fetch handler to serve your [MCP server](https://developers.cloudflare.com/agents/model-context-protocol/). Use it when you want a stateless MCP server that runs in a plain Worker (no Durable Object). For stateful MCP servers that persist state across requests, use the [McpAgent](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/) class instead.

It uses an implementation of the MCP Transport interface, `WorkerTransport`, built on top of web standards, which conforms to the [streamable-http ↗](https://modelcontextprotocol.io/specification/draft/basic/transports/#streamable-http) transport specification.

TypeScript

```
import { createMcpHandler, type CreateMcpHandlerOptions } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
function createMcpHandler(  server: McpServer,  options?: CreateMcpHandlerOptions,): (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>;
```

#### Parameters

* **server** — An instance of [McpServer ↗](https://modelcontextprotocol.io/docs/develop/build-server#node) from the `@modelcontextprotocol/sdk` package
* **options** — Optional configuration object (see [CreateMcpHandlerOptions](#createmcphandleroptions))

#### Returns

A Worker fetch handler function with the signature `(request: Request, env: unknown, ctx: ExecutionContext) => Promise<Response>`.

### CreateMcpHandlerOptions

Configuration options for creating an MCP handler.

TypeScript

```
interface CreateMcpHandlerOptions extends WorkerTransportOptions {  /**   * The route path that this MCP handler should respond to.   * If specified, the handler will only process requests that match this route.   * @default "/mcp"   */  route?: string;
  /**   * An optional auth context to use for handling MCP requests.   * If not provided, the handler will look for props in the execution context.   */  authContext?: McpAuthContext;
  /**   * An optional transport to use for handling MCP requests.   * If not provided, a WorkerTransport will be created with the provided WorkerTransportOptions.   */  transport?: WorkerTransport;
  // Inherited from WorkerTransportOptions:  sessionIdGenerator?: () => string;  enableJsonResponse?: boolean;  onsessioninitialized?: (sessionId: string) => void;  corsOptions?: CORSOptions;  storage?: MCPStorageApi;}
```

#### Options

##### route

The URL path where the MCP handler responds. Requests to other paths return a 404 response.

**Default:** `"/mcp"`

* [  JavaScript ](#tab-panel-5991)
* [  TypeScript ](#tab-panel-5992)

JavaScript

```
const handler = createMcpHandler(server, {  route: "/api/mcp", // Only respond to requests at /api/mcp});
```

TypeScript

```
const handler = createMcpHandler(server, {  route: "/api/mcp", // Only respond to requests at /api/mcp});
```

#### authContext

An authentication context object that will be available to MCP tools via [getMcpAuthContext()](https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/#authentication-context).

When using the [OAuthProvider](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/) from `@cloudflare/workers-oauth-provider`, the authentication context is automatically populated with information from the OAuth flow. You typically don't need to set this manually.

#### transport

A custom `WorkerTransport` instance. If not provided, a new transport is created on every request.

* [  JavaScript ](#tab-panel-5993)
* [  TypeScript ](#tab-panel-5994)

JavaScript

```
import { createMcpHandler, WorkerTransport } from "agents/mcp";
const transport = new WorkerTransport({  sessionIdGenerator: () => `session-${crypto.randomUUID()}`,  storage: {    get: () => myStorage.get("transport-state"),    set: (state) => myStorage.put("transport-state", state),  },});
const handler = createMcpHandler(server, { transport });
```

TypeScript

```
import { createMcpHandler, WorkerTransport } from "agents/mcp";
const transport = new WorkerTransport({  sessionIdGenerator: () => `session-${crypto.randomUUID()}`,  storage: {    get: () => myStorage.get("transport-state"),    set: (state) => myStorage.put("transport-state", state),  },});
const handler = createMcpHandler(server, { transport });
```

## Stateless MCP Servers

Many MCP Servers are stateless, meaning they do not maintain any session state between requests. The `createMcpHandler` function is a lightweight alternative to the `McpAgent` class that can be used to serve an MCP server straight from a Worker. View the [complete example on GitHub ↗](https://github.com/cloudflare/agents/tree/main/examples/mcp-worker).

Breaking change in MCP SDK 1.26.0

**Important:** If you are upgrading from MCP SDK versions before 1.26.0, you must update how you create `McpServer` instances in stateless servers.

MCP SDK 1.26.0 introduces a guard that prevents connecting to a server instance that has already been connected to a transport. This fixes a security vulnerability ([CVE ↗](https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-345p-7cg4-v4c7)) where sharing server or transport instances could leak cross-client response data.

**If your stateless MCP server declares `McpServer` or transport instances in the global scope, you must create new instances per request.**

See the [migration guide](https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/#migration-guide-for-mcp-sdk-1260) below for details.

* [  JavaScript ](#tab-panel-6013)
* [  TypeScript ](#tab-panel-6014)

JavaScript

```
import { createMcpHandler } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
function createServer() {  const server = new McpServer({    name: "Hello MCP Server",    version: "1.0.0",  });
  server.tool(    "hello",    "Returns a greeting message",    { name: z.string().optional() },    async ({ name }) => {      return {        content: [          {            text: `Hello, ${name ?? "World"}!`,            type: "text",          },        ],      };    },  );
  return server;}
export default {  fetch: async (request, env, ctx) => {    // Create new server instance per request    const server = createServer();    return createMcpHandler(server)(request, env, ctx);  },};
```

TypeScript

```
import { createMcpHandler } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
function createServer() {  const server = new McpServer({    name: "Hello MCP Server",    version: "1.0.0",  });
  server.tool(    "hello",    "Returns a greeting message",    { name: z.string().optional() },    async ({ name }) => {      return {        content: [          {            text: `Hello, ${name ?? "World"}!`,            type: "text",          },        ],      };    },  );
  return server;}
export default {  fetch: async (request: Request, env: Env, ctx: ExecutionContext) => {    // Create new server instance per request    const server = createServer();    return createMcpHandler(server)(request, env, ctx);  },} satisfies ExportedHandler<Env>;
```

Each request to this MCP server creates a new session and server instance. The server does not maintain state between requests. This is the simplest way to implement an MCP server.

## Stateful MCP Servers

For stateful MCP servers that need to maintain session state across multiple requests, you can use the `createMcpHandler` function with a `WorkerTransport` instance directly in an `Agent`. This is useful if you want to make use of advanced client features like elicitation and sampling.

Provide a custom `WorkerTransport` with persistent storage. View the [complete example on GitHub ↗](https://github.com/cloudflare/agents/tree/main/examples/mcp-elicitation).

* [  JavaScript ](#tab-panel-6015)
* [  TypeScript ](#tab-panel-6016)

JavaScript

```
import { Agent } from "agents";import { createMcpHandler, WorkerTransport } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const STATE_KEY = "mcp-transport-state";
export class MyStatefulMcpAgent extends Agent {  server = new McpServer({    name: "Stateful MCP Server",    version: "1.0.0",  });
  transport = new WorkerTransport({    sessionIdGenerator: () => this.name,    storage: {      get: () => {        return this.ctx.storage.get(STATE_KEY);      },      set: (state) => {        this.ctx.storage.put(STATE_KEY, state);      },    },  });
  async onRequest(request) {    return createMcpHandler(this.server, {      transport: this.transport,    })(request, this.env, this.ctx);  }}
```

TypeScript

```
import { Agent } from "agents";import {  createMcpHandler,  WorkerTransport,  type TransportState,} from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const STATE_KEY = "mcp-transport-state";
type State = { counter: number };
export class MyStatefulMcpAgent extends Agent<Env, State> {  server = new McpServer({    name: "Stateful MCP Server",    version: "1.0.0",  });
  transport = new WorkerTransport({    sessionIdGenerator: () => this.name,    storage: {      get: () => {        return this.ctx.storage.get<TransportState>(STATE_KEY);      },      set: (state: TransportState) => {        this.ctx.storage.put(STATE_KEY, state);      },    },  });
  async onRequest(request: Request) {    return createMcpHandler(this.server, {      transport: this.transport,    })(request, this.env, this.ctx as unknown as ExecutionContext);  }}
```

In this case we are defining the `sessionIdGenerator` to return the Agent name as the session ID. To make sure we route to the correct Agent we can use `getAgentByName` in the Worker handler:

* [  JavaScript ](#tab-panel-5999)
* [  TypeScript ](#tab-panel-6000)

JavaScript

```
import { getAgentByName } from "agents";
export default {  async fetch(request, env, ctx) {    // Extract session ID from header or generate a new one    const sessionId =      request.headers.get("mcp-session-id") ?? crypto.randomUUID();
    // Get the Agent instance by name/session ID    const agent = await getAgentByName(env.MyStatefulMcpAgent, sessionId);
    // Route the MCP request to the agent    return await agent.onRequest(request);  },};
```

TypeScript

```
import { getAgentByName } from "agents";
export default {  async fetch(request: Request, env: Env, ctx: ExecutionContext) {    // Extract session ID from header or generate a new one    const sessionId =      request.headers.get("mcp-session-id") ?? crypto.randomUUID();
    // Get the Agent instance by name/session ID    const agent = await getAgentByName(env.MyStatefulMcpAgent, sessionId);
    // Route the MCP request to the agent    return await agent.onRequest(request);  },} satisfies ExportedHandler<Env>;
```

With persistent storage, the transport preserves:

* Session ID across reconnections
* Protocol version negotiation state
* Initialization status

This allows MCP clients to reconnect and resume their session in the event of a connection loss.

## Migration Guide for MCP SDK 1.26.0

The MCP SDK 1.26.0 introduces a breaking change for stateless MCP servers that addresses a critical security vulnerability where responses from one client could leak to another client when using shared server or transport instances.

### Who is affected?

| Server Type                                 | Affected? | Action Required                                |
| ------------------------------------------- | --------- | ---------------------------------------------- |
| Stateful servers using Agent/Durable Object | No        | No changes needed                              |
| Stateless servers using createMcpHandler    | Yes       | Create new McpServer per request               |
| Stateless servers using raw SDK transport   | Yes       | Create new McpServer and transport per request |

### Why is this necessary?

The previous pattern of declaring `McpServer` instances in the global scope allowed responses from one client to leak to another client. This is a security vulnerability. The new SDK version prevents this by throwing an error if you try to connect a server that is already connected.

### Before (broken with SDK 1.26.0)

* [  JavaScript ](#tab-panel-6005)
* [  TypeScript ](#tab-panel-6006)

JavaScript

```
import { createMcpHandler } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// INCORRECT: Global server instanceconst server = new McpServer({  name: "Hello MCP Server",  version: "1.0.0",});
server.tool("hello", "Returns a greeting", {}, async () => {  return {    content: [{ text: "Hello, World!", type: "text" }],  };});
export default {  fetch: async (request, env, ctx) => {    // This will fail on second request with MCP SDK 1.26.0+    return createMcpHandler(server)(request, env, ctx);  },};
```

TypeScript

```
import { createMcpHandler } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// INCORRECT: Global server instanceconst server = new McpServer({  name: "Hello MCP Server",  version: "1.0.0",});
server.tool("hello", "Returns a greeting", {}, async () => {  return {    content: [{ text: "Hello, World!", type: "text" }],  };});
export default {  fetch: async (request: Request, env: Env, ctx: ExecutionContext) => {    // This will fail on second request with MCP SDK 1.26.0+    return createMcpHandler(server)(request, env, ctx);  },} satisfies ExportedHandler<Env>;
```

### After (correct)

* [  JavaScript ](#tab-panel-6011)
* [  TypeScript ](#tab-panel-6012)

JavaScript

```
import { createMcpHandler } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// CORRECT: Factory function to create server instancefunction createServer() {  const server = new McpServer({    name: "Hello MCP Server",    version: "1.0.0",  });
  server.tool("hello", "Returns a greeting", {}, async () => {    return {      content: [{ text: "Hello, World!", type: "text" }],    };  });
  return server;}
export default {  fetch: async (request, env, ctx) => {    // Create new server instance per request    const server = createServer();    return createMcpHandler(server)(request, env, ctx);  },};
```

TypeScript

```
import { createMcpHandler } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// CORRECT: Factory function to create server instancefunction createServer() {  const server = new McpServer({    name: "Hello MCP Server",    version: "1.0.0",  });
  server.tool("hello", "Returns a greeting", {}, async () => {    return {      content: [{ text: "Hello, World!", type: "text" }],    };  });
  return server;}
export default {  fetch: async (request: Request, env: Env, ctx: ExecutionContext) => {    // Create new server instance per request    const server = createServer();    return createMcpHandler(server)(request, env, ctx);  },} satisfies ExportedHandler<Env>;
```

### For raw SDK transport users

If you are using the raw SDK transport directly (not via `createMcpHandler`), you must also create new transport instances per request:

* [  JavaScript ](#tab-panel-6009)
* [  TypeScript ](#tab-panel-6010)

JavaScript

```
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
function createServer() {  const server = new McpServer({    name: "Hello MCP Server",    version: "1.0.0",  });
  // Register tools...
  return server;}
export default {  async fetch(request) {    // Create new transport and server per request    const transport = new WebStandardStreamableHTTPServerTransport();    const server = createServer();    server.connect(transport);    return transport.handleRequest(request);  },};
```

TypeScript

```
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
function createServer() {  const server = new McpServer({    name: "Hello MCP Server",    version: "1.0.0",  });
  // Register tools...
  return server;}
export default {  async fetch(request: Request) {    // Create new transport and server per request    const transport = new WebStandardStreamableHTTPServerTransport();    const server = createServer();    server.connect(transport);    return transport.handleRequest(request);  },} satisfies ExportedHandler<Env>;
```

### WorkerTransport

The `WorkerTransport` class implements the MCP Transport interface, handling HTTP request/response cycles, Server-Sent Events (SSE) streaming, session management, and CORS.

TypeScript

```
class WorkerTransport implements Transport {  sessionId?: string;  started: boolean;  onclose?: () => void;  onerror?: (error: Error) => void;  onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
  constructor(options?: WorkerTransportOptions);
  async handleRequest(    request: Request,    parsedBody?: unknown,  ): Promise<Response>;  async send(    message: JSONRPCMessage,    options?: TransportSendOptions,  ): Promise<void>;  async start(): Promise<void>;  async close(): Promise<void>;}
```

#### Constructor Options

TypeScript

```
interface WorkerTransportOptions {  /**   * Function that generates a unique session ID.   * Called when a new session is initialized.   */  sessionIdGenerator?: () => string;
  /**   * Enable traditional Request/Response mode, disabling streaming.   * When true, responses are returned as JSON instead of SSE streams.   * @default false   */  enableJsonResponse?: boolean;
  /**   * Callback invoked when a session is initialized.   * Receives the generated or restored session ID.   */  onsessioninitialized?: (sessionId: string) => void;
  /**   * CORS configuration for cross-origin requests.   * Configures Access-Control-* headers.   */  corsOptions?: CORSOptions;
  /**   * Optional storage API for persisting transport state.   * Use this to store session state in Durable Object/Agent storage   * so it survives hibernation/restart.   */  storage?: MCPStorageApi;}
```

#### sessionIdGenerator

Provides a custom session identifier. This session identifier is used to identify the session in the MCP Client.

* [  JavaScript ](#tab-panel-5995)
* [  TypeScript ](#tab-panel-5996)

JavaScript

```
const transport = new WorkerTransport({  sessionIdGenerator: () => `user-${Date.now()}-${Math.random()}`,});
```

TypeScript

```
const transport = new WorkerTransport({  sessionIdGenerator: () => `user-${Date.now()}-${Math.random()}`,});
```

#### enableJsonResponse

Disables SSE streaming and returns responses as standard JSON.

* [  JavaScript ](#tab-panel-5997)
* [  TypeScript ](#tab-panel-5998)

JavaScript

```
const transport = new WorkerTransport({  enableJsonResponse: true, // Disable streaming, return JSON responses});
```

TypeScript

```
const transport = new WorkerTransport({  enableJsonResponse: true, // Disable streaming, return JSON responses});
```

#### onsessioninitialized

A callback that fires when a session is initialized, either by creating a new session or restoring from storage.

* [  JavaScript ](#tab-panel-6001)
* [  TypeScript ](#tab-panel-6002)

JavaScript

```
const transport = new WorkerTransport({  onsessioninitialized: (sessionId) => {    console.log(`MCP session initialized: ${sessionId}`);  },});
```

TypeScript

```
const transport = new WorkerTransport({  onsessioninitialized: (sessionId) => {    console.log(`MCP session initialized: ${sessionId}`);  },});
```

#### corsOptions

Configure CORS headers for cross-origin requests.

TypeScript

```
interface CORSOptions {  origin?: string;  methods?: string;  headers?: string;  maxAge?: number;  exposeHeaders?: string;}
```

* [  JavaScript ](#tab-panel-6003)
* [  TypeScript ](#tab-panel-6004)

JavaScript

```
const transport = new WorkerTransport({  corsOptions: {    origin: "https://example.com",    methods: "GET, POST, OPTIONS",    headers: "Content-Type, Authorization",    maxAge: 86400,  },});
```

TypeScript

```
const transport = new WorkerTransport({  corsOptions: {    origin: "https://example.com",    methods: "GET, POST, OPTIONS",    headers: "Content-Type, Authorization",    maxAge: 86400,  },});
```

#### storage

Persist transport state to survive Durable Object hibernation or restarts.

TypeScript

```
interface MCPStorageApi {  get(): Promise<TransportState | undefined> | TransportState | undefined;  set(state: TransportState): Promise<void> | void;}
interface TransportState {  sessionId?: string;  initialized: boolean;  protocolVersion?: ProtocolVersion;}
```

* [  JavaScript ](#tab-panel-6007)
* [  TypeScript ](#tab-panel-6008)

JavaScript

```
// Inside an Agent or Durable Object class method:const transport = new WorkerTransport({  storage: {    get: async () => {      return await this.ctx.storage.get("mcp-state");    },    set: async (state) => {      await this.ctx.storage.put("mcp-state", state);    },  },});
```

TypeScript

```
// Inside an Agent or Durable Object class method:const transport = new WorkerTransport({  storage: {    get: async () => {      return await this.ctx.storage.get<TransportState>("mcp-state");    },    set: async (state) => {      await this.ctx.storage.put("mcp-state", state);    },  },});
```

## Authentication Context

When using [OAuth authentication](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/) with `createMcpHandler`, user information is made available to your MCP tools through `getMcpAuthContext()`. Under the hood this uses `AsyncLocalStorage` to pass the request to the tool handler, keeping the authentication context available.

TypeScript

```
interface McpAuthContext {  props: Record<string, unknown>;}
```

### getMcpAuthContext

Retrieve the current authentication context within an MCP tool handler. This returns user information that was populated by the OAuth provider. Note that if using `McpAgent`, this information is accessible directly on `this.props` instead.

TypeScript

```
import { getMcpAuthContext } from "agents/mcp";
function getMcpAuthContext(): McpAuthContext | undefined;
```

* [  JavaScript ](#tab-panel-6019)
* [  TypeScript ](#tab-panel-6020)

JavaScript

```
import { getMcpAuthContext } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
function createServer() {  const server = new McpServer({ name: "Auth Server", version: "1.0.0" });
  server.tool("getProfile", "Get the current user's profile", {}, async () => {    const auth = getMcpAuthContext();    const username = auth?.props?.username;    const email = auth?.props?.email;
    return {      content: [        {          type: "text",          text: `User: ${username ?? "anonymous"}, Email: ${email ?? "none"}`,        },      ],    };  });
  return server;}
```

TypeScript

```
import { getMcpAuthContext } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
function createServer() {  const server = new McpServer({ name: "Auth Server", version: "1.0.0" });
  server.tool("getProfile", "Get the current user's profile", {}, async () => {    const auth = getMcpAuthContext();    const username = auth?.props?.username as string | undefined;    const email = auth?.props?.email as string | undefined;
    return {      content: [        {          type: "text",          text: `User: ${username ?? "anonymous"}, Email: ${email ?? "none"}`,        },      ],    };  });
  return server;}
```

Note

For a complete guide on setting up OAuth authentication with MCP servers, see the [MCP Authorization documentation](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/). View the [complete authenticated MCP server in a Worker example on GitHub ↗](https://github.com/cloudflare/agents/tree/main/examples/mcp-worker-authenticated).

## Error Handling

The `createMcpHandler` automatically catches errors and returns JSON-RPC error responses with code `-32603` (Internal error).

* [  JavaScript ](#tab-panel-6017)
* [  TypeScript ](#tab-panel-6018)

JavaScript

```
server.tool("riskyOperation", "An operation that might fail", {}, async () => {  if (Math.random() > 0.5) {    throw new Error("Random failure occurred");  }  return {    content: [{ type: "text", text: "Success!" }],  };});
// Errors are automatically caught and returned as:// {//   "jsonrpc": "2.0",//   "error": {//     "code": -32603,//     "message": "Random failure occurred"//   },//   "id": <request_id>// }
```

TypeScript

```
server.tool("riskyOperation", "An operation that might fail", {}, async () => {  if (Math.random() > 0.5) {    throw new Error("Random failure occurred");  }  return {    content: [{ type: "text", text: "Success!" }],  };});
// Errors are automatically caught and returned as:// {//   "jsonrpc": "2.0",//   "error": {//     "code": -32603,//     "message": "Random failure occurred"//   },//   "id": <request_id>// }
```

## Related Resources

[ Building MCP Servers ](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) Build and deploy MCP servers on Cloudflare. 

[ MCP Tools ](https://developers.cloudflare.com/agents/model-context-protocol/protocol/tools/) Add tools to your MCP server. 

[ MCP Authorization ](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/) Authenticate users with OAuth. 

[ McpAgent API ](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/) Build stateful MCP servers.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/#page","headline":"createMcpHandler · Cloudflare Agents docs","description":"Create a stateless MCP server fetch handler for a plain Worker using createMcpHandler and streamable HTTP transport.","url":"https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/apis/","name":"APIs"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/apis/handler-api/","name":"createMcpHandler"}}]}
```

---

---
title: MCP server portals
description: MCP server portals in Access.
image: https://developers.cloudflare.com/zt-preview.png
---

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

[Skip to content](#%5Ftop) 

# MCP server portals

An MCP server portal centralizes multiple [Model Context Protocol (MCP) servers ↗](https://www.cloudflare.com/learning/ai/what-is-model-context-protocol-mcp/) onto a single HTTP endpoint.

![MCP clients connect through an MCP portal to access internal MCP servers and SaaS MCP servers.](https://developers.cloudflare.com/_astro/mcp-portal.B5web1ii_2x3Bsf.webp) 

This guide explains how to add MCP servers to Cloudflare Access, create an MCP portal with customized tools and policies, and connect users to the portal using an MCP client.

## Key features

MCP server portals provide the following capabilities:

* **Streamlined access to multiple MCP servers**: MCP server portals support both unauthenticated MCP servers and MCP servers secured using OAuth (for example, via [Access for SaaS](https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/secure-mcp-servers/) or a [third-party OAuth provider](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/)). Users log in to the portal URL through Cloudflare Access and are prompted to authenticate separately to each server that requires OAuth.
* **Customized tools per portal**: Admins can tailor an MCP portal to a particular use case by choosing the specific tools and prompt templates that they want to make available to users through the portal. This allows users to access a curated set of tools and prompts — the less external context exposed to the AI model, the better the AI responses tend to be.
* **Tool and prompt aliases**: Admins can [rename tools and prompts](#rename-tools-and-prompts-with-aliases) and edit their descriptions at the portal or server level without modifying the upstream MCP server. Aliases help end users find the right tool and help AI agents select the correct one.
* **Context optimization**: Portals support query parameter options that reduce context window usage by minimizing or hiding tool definitions. Refer to [Optimize context](#optimize-context) for details.
* **Non-browser client support**: MCP clients authenticate to the portal using a standard OAuth 2.0 authorization code flow via [managed OAuth](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/). Non-browser clients receive a `401` response with a `WWW-Authenticate` header pointing to Access's OAuth discovery endpoints, rather than a browser redirect. You can also connect using [Access service tokens](#connect-with-a-service-token) for machine-to-machine access.
* **Code Mode**: Code Mode is available by default on all portals. It collapses all upstream tools into a single `code` tool. The AI agent writes JavaScript that calls typed methods for each tool, and the code runs in an isolated [Dynamic Worker](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) environment. This keeps context window usage fixed regardless of how many tools are available. Refer to [Code Mode](#code-mode) for connection instructions.
* **Observability**: Once the user's AI agent is connected to the portal, Cloudflare Access logs the individual requests made using the tools in the portal. You can optionally route portal traffic through [Cloudflare Gateway](#route-portal-traffic-through-gateway) for richer HTTP logging and data loss prevention (DLP) scanning.

## How it works

When a user connects an MCP client to a portal, the following flow occurs:

1. The MCP client sends a request to the portal URL (`https://<subdomain>.<domain>/mcp`).
2. Cloudflare Access authenticates the user via a browser-based OAuth 2.0 flow (or [service token](#connect-with-a-service-token) headers).
3. The portal establishes an MCP session and returns the list of available tools from all enabled upstream servers.
4. When the user calls a tool, the portal identifies the target upstream MCP server based on the [tool namespace](#tool-namespacing), attaches the appropriate credentials (user OAuth token or admin credential), and proxies the request.
5. If [Gateway routing](#route-portal-traffic-through-gateway) is turned on, the outbound request passes through Cloudflare Gateway for HTTP logging and DLP inspection before reaching the upstream server.
6. The upstream server's response is returned to the MCP client.

### Transport

The portal connects to upstream MCP servers using [Streamable HTTP ↗](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/transports/#streamable-http) or [SSE ↗](https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/transports/#server-sent-events-sse-deprecated) transport. You do not need to specify which transport your upstream server uses. The portal automatically detects the correct transport by trying multiple connection strategies in order:

| Upstream URL pattern | Connection strategies (in order)                                                                                    |
| -------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Ends in /mcp         | Streamable HTTP only                                                                                                |
| Ends in /sse         | SSE (or Streamable HTTP if Gateway routing is turned on)                                                            |
| All other URLs       | Streamable HTTP on original URL, then SSE on original URL, then Streamable HTTP on {url}/mcp, then SSE on {url}/sse |

If a connection attempt returns a `404`, `405`, or `406` error, the portal falls back to the next strategy. All other errors stop the connection attempt.

### Built-in portal tools

Every portal exposes the following built-in tools to MCP clients, in addition to the upstream server tools:

| Tool                           | Description                                                                                         |
| ------------------------------ | --------------------------------------------------------------------------------------------------- |
| portal\_list\_servers          | Lists all available upstream servers with their ID, name, and whether they are currently turned on. |
| portal\_toggle\_servers        | Opens a URL-based server selection page where you can turn servers on or off.                       |
| portal\_toggle\_single\_server | Turns a single server on or off by server ID, without leaving the MCP client.                       |

When [context optimization](#optimize-context) is turned on, additional tools are exposed depending on the mode:

| Mode                 | Additional tools                                                                    |
| -------------------- | ----------------------------------------------------------------------------------- |
| minimize\_tools      | portal\_query\_tools — Search tools by regex pattern and return full definitions.   |
| search\_and\_execute | portal\_query\_tools and portal\_execute — Search tools and execute them via proxy. |

### Session lifecycle

Each MCP client connection creates a session that persists until the user disconnects or the session expires due to inactivity. Sessions expire after 24 hours of inactivity.

Within a session, users can turn individual servers on or off without disconnecting. Server toggles are scoped to the session and do not affect other users or sessions on the same portal.

### Naming

MCP server portals were previously referred to as **Agents Gateway** in some contexts. The API paths, Terraform resources, and internal codebases may still use `agents_gateway` or `agw` prefixes. The product name is **MCP server portals** and the dashboard navigation is **AI controls**.

## Prerequisites

* An [active domain on Cloudflare](https://developers.cloudflare.com/fundamentals/manage-domains/add-site/)
* Domain uses either a [full setup](https://developers.cloudflare.com/dns/zone-setups/full-setup/) or a [partial (CNAME) setup](https://developers.cloudflare.com/dns/zone-setups/partial-setup/)
* An [identity provider](https://developers.cloudflare.com/cloudflare-one/integrations/identity-providers/) configured on Cloudflare Zero Trust

## Add an MCP server

Add individual MCP servers to Cloudflare Access to bring them under centralized management.

To add an MCP server:

1. In the [Cloudflare dashboard ↗](https://dash.cloudflare.com/), go to **Zero Trust** \> **Access controls** \> **AI controls**.
2. Go to the **MCP servers** tab.
3. Select **Add an MCP server**.
4. Enter any name for the server.
5. (Optional) Enter a custom string for the **Server ID**.
6. In **HTTP URL**, enter the full URL of your MCP server. For example, if you want to add the [Cloudflare Documentation MCP server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/docs-vectorize), enter `https://docs.mcp.cloudflare.com/mcp`.
7. Add [Access policies](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/) to show or hide the server in an [MCP server portal](#create-a-portal). The MCP server link will only appear in the portal for users who match an Allow policy. Users who do not pass an Allow policy will not see this server through any portals.  
Warning  
Blocked users can still connect to the server (and bypass your Access policies) by using its direct URL. If you want to enforce authentication through Cloudflare Access, [configure Access as the server's OAuth provider](https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/secure-mcp-servers/).
8. Select **Save and connect server**.
9. If the MCP server supports OAuth, you will be redirected to log in to your OAuth provider. You can log in to any account on the MCP server. The account used to authenticate will serve as the admin credential for that MCP server. You can [configure an MCP portal](#create-a-portal) to use this admin credential to make requests.

Cloudflare Access will validate the server connection and retrieve a list of resources, prompts, and tools. Once the server is successfully connected, the [server status](#server-status) will change to **Ready**. You can now add the MCP server to an [MCP server portal](#create-a-portal).

### MCP Apps

[MCP Apps ↗](https://modelcontextprotocol.io/extensions/apps/overview) — tools that declare a UI resource in their description — will also be available after successfully connecting to an MCP server. A list of MCP clients that support MCP Apps is available in the [Extension Support Matrix ↗](https://modelcontextprotocol.io/extensions/client-matrix).

### Server status

The MCP server status indicates the synchronization status of the MCP server to Cloudflare Access.

| Status        | Description                                                                                                                                                                                         |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Error         | The server could not be reached or returned an error. Refer to [error details](#error-details) for more information. To fix the issue, [reauthenticate the server](#reauthenticate-the-mcp-server). |
| Sync Required | The server's OAuth credentials can no longer be refreshed and the server needs to be reauthenticated. To fix the issue, [reauthenticate the server](#reauthenticate-the-mcp-server).                |
| Waiting       | The server's tools, prompts, and resources are being synchronized.                                                                                                                                  |
| Ready         | The server was successfully synchronized and all tools, prompts, and resources are available.                                                                                                       |

#### Error details

When an MCP server is in the **Error** or **Sync Required** state, Cloudflare Access surfaces structured information to help you diagnose the issue. In the dashboard, hover over the server's status to view the error message, the error category (upstream or connection), the HTTP status code, and the MCP protocol error code (if applicable). The same details are returned by the API as an `error_details` object:

| Field              | Description                                                                                                                                      |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| message            | A human-readable description of the error.                                                                                                       |
| type               | The category of error — for example, upstream\_error (the server returned an error response) or unreachable (the server could not be contacted). |
| http\_status\_code | The HTTP status code returned by the upstream server, if applicable.                                                                             |
| mcp\_error\_code   | The MCP protocol error code, if the server returned an MCP-level error.                                                                          |

Common causes of server errors include expired OAuth credentials, unreachable server URLs, and upstream server misconfigurations. If the error type is `upstream_error`, check the HTTP and MCP error codes to identify the issue on the upstream server. If the type is `unreachable`, verify that the server URL is correct and accessible.

### Reauthenticate the MCP server

To reauthenticate an MCP server in Cloudflare Access:

1. In the [Cloudflare dashboard ↗](https://dash.cloudflare.com/), go to **Zero Trust** \> **Access controls** \> **AI controls**.
2. Go to the **MCP servers** tab.
3. Select the server that you want to reauthenticate, then select **Edit**.
4. Select **Authenticate server**.

You will be redirected to log in to your OAuth provider. The account used to authenticate will serve as the new admin credential for this MCP server.

### Synchronize the MCP server

Cloudflare Access automatically synchronizes tools and prompts from your MCP server approximately every two hours. During synchronization, Cloudflare connects to your MCP server using the [admin credential](#reauthenticate-the-mcp-server) and fetches the current list of tools and prompts. If the admin credential's OAuth access token has expired, Cloudflare refreshes it automatically using the stored refresh token before connecting.

Note

Synchronization uses the admin credential, not individual user credentials. If the admin credential's refresh token has expired or been revoked, the [server status](#server-status) will change to **Sync Required** and you will need to [reauthenticate the server](#reauthenticate-the-mcp-server). This will not impact end users' ability to connect to the MCP server. It will impact the ability to fetch tool and prompt information or additions and removals.

To manually refresh the MCP server in Zero Trust:

1. In the [Cloudflare dashboard ↗](https://dash.cloudflare.com/), go to **Zero Trust** \> **Access controls** \> **AI controls**.
2. Go to the **MCP servers** tab and find the server that you want to refresh.
3. Select the three dots > **Sync capabilities**.

The MCP server page will show the updated list of tools and prompts. New tools and prompts are automatically enabled in the MCP server portal.

You can also trigger a sync via the API. The sync endpoint returns the current server state after synchronization, including the updated [server status](#server-status), tool count, and [error details](#error-details) if the sync failed.

### Upstream OAuth callback URL

When a user authorizes an upstream MCP server that requires per-user OAuth, the portal performs an OAuth authorization code flow with the upstream server on the user's behalf. As part of this flow, the portal registers a callback URL (`redirect_uri`) with the upstream server. The upstream server redirects to this URL after the user authorizes access.

By default, the portal uses a callback URL on your portal domain:

```
https://<your-portal-hostname>/servers-callback
```

Allowlist this URL as a redirect URI at the upstream OAuth provider. OAuth providers typically exact-match the full URI including path.

#### Shared Cloudflare callback URL (opt-in)

If you have turned on the shared callback URL for the portal, the portal uses a Cloudflare-owned URL instead:

```
https://oauth-callbacks.cloudflareaccess.com/cdn-cgi/access/outbound-oauth-callback
```

Use the shared callback URL when upstream vendors only allow a small number of redirect URIs in their allowlist, or when you want to use a single Cloudflare-owned URL across multiple portals. The shared callback URL is only used when explicitly turned on for the portal.

Note

If an upstream OAuth provider rejects the callback URL, verify that the correct URL for the portal (`https://<your-portal-hostname>/servers-callback` by default, or the shared Cloudflare URL when turned on) is allowlisted as a redirect URI at the upstream provider. OAuth providers typically exact-match the full URI including path.

## Create a portal

To create an MCP server portal:

1. In the [Cloudflare dashboard ↗](https://dash.cloudflare.com/), go to **Zero Trust** \> **Access controls** \> **AI controls**.
2. Select **Add MCP server portal**.
3. Enter any name for the portal.
4. Under **Custom domain**, select a domain for the portal URL. Domains must belong to an active zone in your Cloudflare account. You can optionally specify a subdomain.
5. [Add MCP servers](#add-an-mcp-server) to the portal.
6. (Optional) Under **MCP servers**, [configure the tools and prompts](#manage-tools-and-prompts) available through the portal.
7. (Optional) Configure **Require user auth** for servers that support OAuth: - `Enabled`: (default) User will be prompted to utilize their own login credentials to establish a connection with the MCP server. - `Disabled`: Users who are connected to the portal will automatically have access to the MCP server via its [admin credential](#reauthenticate-the-mcp-server).
8. Add [Access policies](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/) to define the users who can connect to the portal URL.  
Warning  
[Independent MFA](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/mfa-requirements/#independent-mfa), [purpose justification](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/require-purpose-justification/), and [temporary authentication](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/temporary-auth/) will not be enforced for MCP servers that are authorized through an MCP portal. For example, if independent MFA is enabled on a policy assigned to a server, users will not be prompted to perform MFA to authorize the server after authenticating to the portal. Refer to [Policy limitations](#policy-limitations) for details.
9. Select **Add an MCP server portal**.
10. (Optional) [Customize the login experience](#customize-login-settings) for the portal.

Users can now [connect to the portal](#connect-to-a-portal) at `https://<subdomain>.<domain>/mcp` using an MCP client.

### Customize login settings

Cloudflare Access automatically creates an Access application for each MCP server portal. You can customize the portal login experience by updating Access application settings:

1. In the [Cloudflare dashboard ↗](https://dash.cloudflare.com/), go to **Zero Trust** \> **Access controls** \> **Applications**.
2. Find the portal that you want to configure, then select the three dots > **Edit**.
3. To configure identity providers for the portal:  
  1. Go to **Authentication**.
  2. Select the [identity providers](https://developers.cloudflare.com/cloudflare-one/integrations/identity-providers/) that you want to enable for your application.
  3. (Recommended) If you plan to only allow access via a single identity provider, turn on **Apply instant authentication**. End users will not be shown the [Cloudflare Access login page](https://developers.cloudflare.com/cloudflare-one/reusable-components/custom-pages/access-login-page/). Instead, Cloudflare will redirect users directly to your SSO login event.
4. To customize the block page:  
  1. Go to **Additional settings**.
  2. **Custom block pages**: Choose what users will see when they are denied access to the application.

    * **Cloudflare default**: Reload the [login page](https://developers.cloudflare.com/cloudflare-one/reusable-components/custom-pages/access-login-page/) and display a block message below the Cloudflare Access logo. The default message is `That account does not have access`, or you can enter a custom message.
    * **Redirect URL**: Redirect to the specified website.
    * **Custom page template**: Display a [custom block page](https://developers.cloudflare.com/cloudflare-one/reusable-components/custom-pages/access-block-page/) hosted in Cloudflare One.
5. Select **Save**.

## Manage tools and prompts

When you add an MCP server to a portal, all of its tools and prompts are available to portal users by default. You can customize which tools and prompts are exposed, rename them with aliases, and override their descriptions.

### Turn off individual tools or prompts

To hide specific tools or prompts from portal users:

1. In the [Cloudflare dashboard ↗](https://dash.cloudflare.com/), go to **Zero Trust** \> **Access controls** \> **AI controls**.
2. Find the portal you want to configure, then select the three dots > **Edit**.
3. Under **MCP servers**, find the server whose tools you want to manage.
4. Turn off the toggle next to any tool or prompt that you want to hide from users.
5. Select **Save**.

Turned-off tools will not appear in the portal's tool list. Users will not be able to call them.

### Use an allowlist pattern

By default, all tools and prompts from an MCP server are available in the portal. You can invert this behavior so that all tools are hidden by default and only explicitly turned-on tools are exposed. This is useful when an MCP server has many tools but you only want to expose a curated subset.

To configure an allowlist via the API, set `default_disabled` to `true` on the server-to-portal mapping, then explicitly list the tools you want to expose in `updated_tools`:

API request body (portal update)

```
{  "servers": [    {      "id": "example-server",      "default_disabled": true,      "updated_tools": [        {          "name": "search_documents",          "enabled": true        },        {          "name": "list_projects",          "enabled": true        }      ]    }  ]}
```

With `default_disabled` set to `true`, only `search_documents` and `list_projects` will be available to portal users. All other tools from this server will be hidden.

### Rename tools and prompts with aliases

Aliases let you give tools and prompts clearer names in the portal. Use aliases to:

* Replace unclear tool names with names that match your organization's terminology.
* Add or improve descriptions so AI agents select the correct tool.
* Standardize naming across multiple MCP servers in a portal.

Alias names must be 1-40 characters and can only contain letters, numbers, hyphens, and underscores. Names must start and end with an alphanumeric character. The value must match `^[a-zA-Z0-9]+([_-][a-zA-Z0-9]+)*$`. For example, `search_customer_records` or `get-user-profile`. No two tools or prompts on the same server can share the same name, whether that name is an alias or the original upstream name.

#### Alias precedence

You can set aliases at two levels. Portal-level aliases take precedence over server-level aliases.

| Level            | Field         | Scope                                                         |
| ---------------- | ------------- | ------------------------------------------------------------- |
| **Server-level** | alias         | Applies across all portals that include this server           |
| **Portal-level** | portal\_alias | Applies only within a specific portal; overrides server-level |

When multiple names exist, the portal resolves them in this order: `portal_alias` \> `server_alias` \> `alias` \> original tool name.

If no alias is set, the portal uses the original name and description from the upstream server.

Custom descriptions follow the same precedence. Set a description by including the `description` field on an entry in `updated_tools` or `updated_prompts`. In API responses, server-level descriptions are returned as `server_description` and portal-level descriptions are returned as `portal_description`. Portal-level descriptions take precedence over server-level descriptions when both are set.

#### Set aliases in the dashboard

* [ Portal-level alias ](#tab-panel-7393)
* [ Server-level alias ](#tab-panel-7394)

To set an alias that applies to a specific portal:

1. In the [Cloudflare dashboard ↗](https://dash.cloudflare.com/), go to **Zero Trust** \> **Access controls** \> **AI controls**.
2. Find the portal you want to configure, then select the three dots > **Edit**.
3. Go to the **Servers** tab.
4. Select the **Tools authorized** or **Prompts authorized** value for the server you want to configure (for example, `10/10`).
5. Find the tool or prompt you want to modify, then select the three dots > **Edit**.
6. In the modal, update the **Name** and **Description** as needed.
7. Select **Confirm**.

To set an alias that applies across all portals using a server:

1. In the [Cloudflare dashboard ↗](https://dash.cloudflare.com/), go to **Zero Trust** \> **Access controls** \> **AI controls**.
2. Go to the **MCP servers** tab.
3. Find the server you want to configure, then select the three dots > **Edit**.
4. Go to the **Tools** or **Prompts** tab.
5. Find the tool or prompt you want to modify, then select the three dots > **Edit**.
6. In the modal, update the **Name** and **Description** as needed.
7. Select **Confirm**.
8. Scroll to the bottom of the page and select **Save server**.

Tools and prompts that have been modified display a **Modified** label in the dashboard.

#### Set aliases with the API

Send a `PUT` request to the [update a MCP portal](https://developers.cloudflare.com/api/resources/zero%5Ftrust/subresources/access/subresources/ai%5Fcontrols/subresources/mcp/subresources/portals/methods/update/) endpoint. Include the `alias` field for each tool or prompt you want to rename.

Terminal window

```
curl "https://api.cloudflare.com/client/v4/accounts/%7Baccount_id%7D/access/ai-controls/mcp/portals/%7Bid%7D" \  --request PUT \  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \  --json '{    "servers": [        {            "server_id": "example-server",            "updated_tools": [                {                    "name": "original_tool_name",                    "enabled": true,                    "description": "A clearer description of what this tool does.",                    "alias": "renamed_tool"                }            ],            "updated_prompts": [                {                    "name": "original_prompt_name",                    "enabled": true,                    "description": "An updated description for this prompt.",                    "alias": "renamed_prompt"                }            ]        }    ]  }'
```

To set server-level aliases that apply across all portals, send a `PUT` request to the [update a MCP server](https://developers.cloudflare.com/api/resources/zero%5Ftrust/subresources/access/subresources/ai%5Fcontrols/subresources/mcp/subresources/servers/methods/update/) endpoint with the same `updated_tools` and `updated_prompts` fields.

#### Reset an alias

To reset a tool or prompt to its original upstream name, open the edit modal for the tool or prompt in the dashboard and select "Reset to server definition." When using the API, omit the `alias` field from the corresponding entry in `updated_tools` or `updated_prompts`.

#### How aliases affect end users

MCP clients receive the aliased name and description instead of the original. End users do not see the original name.

If you change an alias while a user has an active session, the user must reauthenticate to see the update. Refer to [Manage portal sessions](#manage-portal-sessions) for reauthentication options.

Warning

If the upstream server renames a tool or prompt, your alias for it will be removed on the next sync. Verify that your aliases still apply after each sync.

### Tool and prompt namespacing

All tools and prompts exposed through a portal are automatically namespaced with the server ID as a prefix. The format is `{server_id}_{original_name}`. For example, a tool named `list_issues` on a server with ID `github` appears as `github_list_issues` in the portal. This prevents name collisions when multiple MCP servers expose tools with the same name.

Prompts follow the same pattern. A prompt named `summarize` on a server with ID `github` appears as `github_summarize`.

#### How the server ID is determined

The server ID used for namespacing comes from the **Server ID** field you set when [adding an MCP server](#add-an-mcp-server). You can enter a custom server ID in step 5 of the setup process, or let Cloudflare generate one automatically.

Choose short, descriptive server IDs when you plan to expose the server through a portal. The server ID becomes part of every tool name that MCP clients and AI agents see.

#### Parsing namespaced names

The portal splits namespaced names on the **first** underscore only. Everything before the first underscore is the server ID, and everything after it is the tool or prompt name. This means tool names can contain underscores without ambiguity.

| Namespaced name               | Server ID | Tool name             |
| ----------------------------- | --------- | --------------------- |
| github\_list\_issues          | github    | list\_issues          |
| github\_create\_pull\_request | github    | create\_pull\_request |
| sentry\_get\_issue\_details   | sentry    | get\_issue\_details   |

Because the split happens on the first underscore, server IDs themselves cannot contain underscores. Use hyphens instead when you need a multi-word server ID (for example, `my-server`).

#### Namespacing with aliases

If you [rename a tool with an alias](#rename-tools-and-prompts-with-aliases), the alias replaces the original tool name in the namespaced format. The server ID prefix still applies.

For example, if you alias the tool `list_issues` to `issues` on a server with ID `github`, the namespaced name becomes `github_issues`.

#### Namespacing in Code Mode

When [Code Mode](#code-mode) is active, the portal applies an additional transformation to make namespaced tool names safe for use as JavaScript identifiers. Hyphens and dots in the namespaced name are replaced with underscores, names that start with a digit get a `_` prefix, and JavaScript reserved words get a `_` suffix. For example, a server with ID `my-server` and a tool named `get-data` would appear as `my_server_get_data` in the Code Mode sandbox.

This sanitization happens automatically. You do not need to call any helper functions when using Code Mode as an end user.

#### Helper functions in the Agents SDK

If you are building an MCP client with the [Agents SDK](https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/), the SDK provides helper functions for working with server IDs and tool names:

* **`normalizeServerId`** (exported from `agents/mcp/client`) normalizes a caller-supplied server ID into a safe string. For example, `"GitHub MCP!"` becomes `"github-mcp"`. The SDK calls this automatically when you pass an `id` option to `addMcpServer()`.
* **`sanitizeToolName`** (exported from `@cloudflare/codemode`) converts a tool name into a valid JavaScript identifier by replacing hyphens and dots with underscores. This is called automatically in Code Mode contexts. Refer to the [Code Mode SDK reference](https://developers.cloudflare.com/agents/tools/codemode/api-reference/#code-and-output-utilities) for details.

Note

The Agents SDK uses a `tool_{server_id}_{tool_name}` format (with a `tool_` prefix) when returning tools from `getAITools()`. This differs from the portal format, which uses `{server_id}_{tool_name}` without the prefix.

### Portal-native tools

In addition to upstream MCP server tools, the portal exposes its own built-in tools that let AI agents manage server connections and discover tools during a session. These tools use the `portal_` prefix and are not associated with any upstream server.

#### Always available

The following tools are available in every portal session, regardless of the connection mode:

| Tool                           | Description                                                                                                                                                                                                                                                                 |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| portal\_list\_servers          | Lists all upstream MCP servers with their IDs, names, and whether they are currently enabled in the session.                                                                                                                                                                |
| portal\_toggle\_servers        | Opens a server selection flow. Returns a URL that the user visits in a browser to enable or disable servers and manage OAuth credentials.                                                                                                                                   |
| portal\_toggle\_single\_server | Toggles a single server on or off without requiring a browser visit. Accepts a server\_id and an action (toggle or untoggle). If the server requires OAuth and the user has not authenticated yet, the portal falls back to the browser-based portal\_toggle\_servers flow. |

These tools power the [session management](#manage-portal-sessions) features described later in this guide. AI agents call them automatically when you ask to enable a server, disable a server, or return to the server selection page.

#### Context optimization tools

When you connect with the [optimize\_context](#optimize-context) query parameter, the portal exposes additional tools for discovering and calling upstream tools:

| Tool                 | Available in                          | Description                                                                                                                                                                                                                                  |
| -------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| portal\_query\_tools | minimize\_tools, search\_and\_execute | Searches upstream tools by name, description, or schema using a regex pattern. Returns full tool definitions so the agent can call them. Required in minimize\_tools mode because upstream tool schemas are stripped to reduce context size. |
| portal\_execute      | search\_and\_execute                  | Calls an upstream tool by name with the provided arguments. In search\_and\_execute mode, upstream tools are hidden from the tool list entirely, so agents must use portal\_query\_tools to discover them and portal\_execute to call them.  |

#### Code Mode tools

When you connect with [Code Mode](#code-mode) enabled, the portal replaces all upstream tools with two code execution tools:

| Tool                      | Description                                                                                                                                                                                                  |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| portal\_codemode\_search  | Searches available tools by running JavaScript in a sandboxed Worker. The sandbox provides a codemode.tools() function that returns all upstream tool definitions with sanitized names.                      |
| portal\_codemode\_execute | Calls upstream tools by running JavaScript in a sandboxed Worker. The sandbox provides a codemode proxy object where each property maps to an upstream tool. Supports Promise.all() for parallel tool calls. |

Refer to the [Code Mode SDK reference](https://developers.cloudflare.com/agents/tools/codemode/api-reference/) for details on writing code for these tools.

## Manage portals via API

In addition to the dashboard, you can manage MCP server portals programmatically using the Cloudflare API. The following examples show common operations.

Warning

Unlike the dashboard, the API does not automatically create a DNS record for your portal hostname. After creating a portal via the API, you must create a proxied CNAME record that points your portal subdomain to `gateway.agents.cloudflare.com`. Without this record, the portal will return `522` errors.

### List portals

Terminal window

```
curl "https://api.cloudflare.com/client/v4/accounts/%7Baccount_id%7D/access/ai-controls/mcp/portals" \  --request GET \  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
```

### Create a portal

Terminal window

```
curl "https://api.cloudflare.com/client/v4/accounts/%7Baccount_id%7D/access/ai-controls/mcp/portals" \  --request POST \  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \  --json '{    "name": "Engineering Portal",    "hostname": "mcp.example.com",    "allow_code_mode": true,    "secure_web_gateway": false  }'
```

### List MCP servers

Terminal window

```
curl "https://api.cloudflare.com/client/v4/accounts/%7Baccount_id%7D/access/ai-controls/mcp/servers" \  --request GET \  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
```

### Create an MCP server

Terminal window

```
curl "https://api.cloudflare.com/client/v4/accounts/%7Baccount_id%7D/access/ai-controls/mcp/servers" \  --request POST \  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \  --json '{    "name": "GitHub MCP Server",    "hostname": "https://github-mcp.example.workers.dev/mcp",    "auth_type": "oauth"  }'
```

The `auth_type` field accepts the following values:

| Value           | Description                                                                                                                                          |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| oauth           | The server requires OAuth authentication. After creating the server, you will need to authenticate via the dashboard to establish admin credentials. |
| bearer          | The server uses a static bearer token for authentication. Provide the token in auth\_credentials.                                                    |
| unauthenticated | The server does not require authentication.                                                                                                          |

### Force sync an MCP server

To manually trigger a synchronization of tools and prompts from an upstream MCP server:

Terminal window

```
curl "https://api.cloudflare.com/client/v4/accounts/%7Baccount_id%7D/access/ai-controls/mcp/servers/%7Bserver_id%7D/sync" \  --request POST \  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
```

### Delete a portal

Terminal window

```
curl "https://api.cloudflare.com/client/v4/accounts/%7Baccount_id%7D/access/ai-controls/mcp/portals/%7Bid%7D" \  --request DELETE \  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
```

## Configure via Terraform

You can manage MCP server portals using the [Cloudflare Terraform provider ↗](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs). Use the `cloudflare_zero_trust_access_mcp_server_portal` resource to create and configure portals programmatically.

Warning

Unlike the dashboard, the Terraform provider does not automatically create DNS records for your portal hostname. You must create a CNAME record that points your portal subdomain to `gateway.agents.cloudflare.com`. Without this record, the portal will return `522` errors.

The following example creates an MCP server portal with a CNAME record:

MCP server portal with DNS record

```
# Create the MCP server portalresource "cloudflare_zero_trust_access_mcp_server_portal" "example" {  account_id = var.cloudflare_account_id  name       = "Engineering Portal"  hostname   = "mcp.example.com"}
# Required: Create the CNAME record for the portal hostnameresource "cloudflare_dns_record" "mcp_portal" {  zone_id = var.cloudflare_zone_id  name    = "mcp"  content = "gateway.agents.cloudflare.com"  type    = "CNAME"  proxied = true}
```

For the full list of supported resource arguments, refer to the [Terraform provider documentation ↗](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs).

## Code Mode

[Code Mode](https://developers.cloudflare.com/agents/tools/codemode/) is turned on by default on all MCP server portals. It reduces context window usage by collapsing all tools in the portal into a single `code` tool. Instead of loading a separate tool definition for each upstream MCP server tool, the connected AI agent writes JavaScript that calls typed `codemode.*` methods. The generated code runs in an isolated [Dynamic Worker](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) environment, which keeps authentication credentials and environment variables out of the model context.

To use Code Mode, the MCP client must request it when connecting to the portal URL. Refer to [Connect with Code Mode](#connect-with-code-mode) for the required query parameter.

Code Mode is useful for portals that aggregate many MCP servers or servers that expose a large number of tools. Context window usage stays fixed regardless of how many tools are available through the portal.

### Connect with Code Mode

To use Code Mode, append the `?codemode=search_and_execute` query string parameter to your portal URL when [connecting](#connect-to-a-portal) from an MCP client.

For example, if your portal URL is `https://<subdomain>.<domain>/mcp`, connect to:

```
https://<subdomain>.<domain>/mcp?codemode=search_and_execute
```

For MCP clients with server configuration files, use the portal URL with the query string parameter:

MCP client configuration with Code Mode

```
{  "mcpServers": {    "example-portal": {      "command": "npx",      "args": [        "-y",        "mcp-remote@latest",        "https://<subdomain>.<domain>/mcp?codemode=search_and_execute"      ]    }  }}
```

When Code Mode is active, the portal advertises a single `code` tool to connected MCP clients. The AI agent discovers available tools by inspecting the typed method signatures in the Dynamic Worker environment and composes multiple tool calls into a single code execution.

For more information on building with Code Mode, refer to the [Code Mode SDK reference](https://developers.cloudflare.com/agents/tools/codemode/api-reference/).

### Turn off Code Mode

To turn off Code Mode for a portal:

* [ Dashboard ](#tab-panel-7395)
* [ API ](#tab-panel-7396)

1. In the [Cloudflare dashboard ↗](https://dash.cloudflare.com/), go to **Zero Trust** \> **Access controls** \> **AI controls**.
2. Find the portal you want to configure, then select the three dots > **Edit**.
3. Under **Basic information**, turn off **Code Mode**.

1. Get your existing MCP portal configuration:  
Terminal window  
```  
curl "https://api.cloudflare.com/client/v4/accounts/%7Baccount_id%7D/access/ai-controls/mcp/portals/%7Bid%7D" \  --request GET \  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN"  
```
2. Send a `PUT` request to the [Update a MCP Portal](https://developers.cloudflare.com/api/resources/zero%5Ftrust/subresources/access/subresources/ai%5Fcontrols/subresources/mcp/subresources/portals/methods/update/) endpoint with `allow_code_mode` set to `false`. To avoid overwriting your existing configuration, the `PUT` request body should contain all fields returned by the previous `GET` request.  
Terminal window  
```  
curl "https://api.cloudflare.com/client/v4/accounts/%7Baccount_id%7D/access/ai-controls/mcp/portals/%7Bid%7D" \  --request PUT \  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \  --json '{    "allow_code_mode": false  }'  
```

## Route portal traffic through Gateway

When Gateway routing is turned on, calls to MCP servers protected by your MCP server portal are routed through [Cloudflare Gateway](https://developers.cloudflare.com/cloudflare-one/traffic-policies/). This makes portal traffic appear in your [Gateway HTTP logs](https://developers.cloudflare.com/cloudflare-one/insights/logs/dashboard-logs/gateway-logs/) alongside the rest of your organization's HTTP traffic. You can then create [Data Loss Prevention (DLP) policies](#example-gateway-policy) to detect and block sensitive data from being sent to your upstream MCP servers.

### How Gateway routing works

When a user calls a tool through the portal, the portal proxies the request to the upstream MCP server. With Gateway routing turned on, this outbound request passes through Cloudflare Gateway before reaching the upstream server. Gateway inspects the traffic and applies any matching HTTP policies, including DLP scanning.

Because portal traffic routes through Gateway, it also respects [Gateway egress policies](https://developers.cloudflare.com/cloudflare-one/traffic-policies/egress-policies/). This means outbound requests to upstream MCP servers will originate from your dedicated egress IPs or Gateway IP ranges rather than generic Cloudflare IPs. If your upstream MCP servers restrict inbound traffic by source IP (for example, to a VPN or corporate IP range), you can use egress policies to ensure portal traffic comes from a predictable set of IPs.

Note

Gateway routing only applies to real-time tool calls made by users through the portal. Background operations such as [admin credential synchronization](#synchronize-the-mcp-server) do not route through Gateway and will not use your egress policy IPs.

### TLS decryption

DLP inspection requires Gateway to decrypt TLS traffic. For portal traffic, Gateway decrypts and inspects the payload automatically — you do not need to turn on the account-level [TLS decryption](https://developers.cloudflare.com/cloudflare-one/traffic-policies/http-policies/tls-decryption/) setting. Because the portal terminates the connection from the MCP client and re-originates the request through Gateway, Gateway decrypts portal traffic regardless of whether the global TLS decryption setting is on.

This automatic decryption applies only to traffic that flows through the portal. To inspect MCP traffic that does not pass through the portal — for example, an agent on a [device running the WARP client](https://developers.cloudflare.com/cloudflare-one/team-and-resources/devices/cloudflare-one-client/) connecting directly to an upstream MCP server — you must turn on [TLS decryption](https://developers.cloudflare.com/cloudflare-one/traffic-policies/http-policies/tls-decryption/) as you would for any other [HTTP policy](https://developers.cloudflare.com/cloudflare-one/traffic-policies/http-policies/).

Note

Portal traffic ignores the global TLS decryption setting, but it still respects [Do Not Inspect](https://developers.cloudflare.com/cloudflare-one/traffic-policies/http-policies/#do-not-inspect) HTTP policies. If a Do Not Inspect policy matches portal traffic, Gateway does not decrypt it and DLP cannot scan it. Check that your Do Not Inspect policies do not unintentionally exempt your upstream MCP servers from inspection.

### Supported transports

Gateway routing supports [Streamable HTTP ↗](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/transports/#streamable-http) connections only. If an upstream MCP server is configured with a Server-Sent Events (SSE) endpoint (a URL ending in `/sse`), the portal will automatically attempt to connect using Streamable HTTP instead. If the upstream server does not support Streamable HTTP, the connection will fail when Gateway routing is turned on.

### Enable Gateway routing

To route MCP server portal traffic through Gateway:

1. In the [Cloudflare dashboard ↗](https://dash.cloudflare.com/), go to **Zero Trust** \> **Access controls** \> **AI controls**.
2. Find the portal you want to configure, then select the three dots > **Edit**.
3. Under **Basic information**, turn on **Route traffic through Cloudflare Gateway**.
4. Select **Save**.

Portal traffic will now appear in your [Gateway HTTP logs](https://developers.cloudflare.com/cloudflare-one/insights/logs/dashboard-logs/gateway-logs/). To apply DLP scanning, [create a Gateway HTTP policy](#example-gateway-policy).

### Example Gateway policy

To scan traffic for sensitive data, [create a Gateway HTTP policy](https://developers.cloudflare.com/cloudflare-one/data-loss-prevention/dlp-policies/) that matches both the MCP server and a predefined or custom [DLP profile](https://developers.cloudflare.com/cloudflare-one/data-loss-prevention/dlp-profiles/).

Gateway HTTP policies for MCP portal traffic must explicitly target the upstream MCP server. Ensure that your policy matches the upstream MCP server hostname (for example, `example-mcp-server.example.workers.dev`) rather than the portal URL (`<subdomain>.<domain>`).

For example, the following policy blocks traffic that contains [credentials and secrets](https://developers.cloudflare.com/cloudflare-one/data-loss-prevention/dlp-profiles/predefined-profiles/#credentials-and-secrets) or [financial information](https://developers.cloudflare.com/cloudflare-one/data-loss-prevention/dlp-profiles/predefined-profiles/#financial-information):

| Selector    | Operator | Value                                              | Logic | Action |
| ----------- | -------- | -------------------------------------------------- | ----- | ------ |
| Host        | in       | example-mcp-server.example.workers.dev             | And   | Block  |
| DLP Profile | in       | _Credentials and Secrets_, _Financial Information_ |       |        |

### What happens when a request is blocked

When a tool call matches a Block DLP policy, Gateway blocks it and the portal surfaces the block to the MCP client as an error rather than completing the tool call. This applies in both directions:

* **Tool call requests**: If the data the agent sends to a tool matches a DLP profile, Gateway blocks the outbound request and the agent receives an error indicating the request was blocked.
* **Tool call responses**: If the data the upstream server returns matches a DLP profile, Gateway blocks the response and the portal returns an error instead of the matched content.

The agent can retry the request, but it will continue to be blocked until the content no longer matches the policy.

### Limitations

* DLP [AI prompt profiles](https://developers.cloudflare.com/cloudflare-one/data-loss-prevention/dlp-profiles/predefined-profiles/#ai-prompt) do not apply to MCP server portal traffic. AI prompt profiles are designed for specific web client API paths and do not match the MCP protocol format. Use standard DLP profiles instead.
* SSE transport is not supported through Gateway. If your upstream MCP server only supports SSE, Gateway routing will not work for that server.
* Background synchronization of tools and prompts does not route through Gateway. Only real-time user requests are inspected.

## Connect to a portal

Users can connect to your MCP server running at `https://<subdomain>.<domain>/mcp` using [Workers AI Playground ↗](https://playground.ai.cloudflare.com/), [MCP inspector ↗](https://github.com/modelcontextprotocol/inspector), or [other MCP clients](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/#connect-your-mcp-server-to-claude-and-other-mcp-clients) that support remote MCP servers.

To test in Workers AI Playground:

1. Go to [Workers AI Playground ↗](https://playground.ai.cloudflare.com/).
2. Under **MCP Servers**, enter `https://<subdomain>.<domain>/mcp` for the portal URL.
3. Select **Connect**.
4. In the popup window, log in to your Cloudflare Access identity provider.
5. The popup window will list the MCP servers in the portal that require authentication. For each of these MCP servers, select **Connect** and follow the login prompts.
6. Select **Done** to complete the portal authentication process.

Workers AI Playground will show a **Connected** status and list the available tools. You can now ask the AI model to complete a task using an available tool. Requests made to an MCP server will appear in your [portal logs](#view-portal-logs).

For MCP clients with server configuration files, we recommend using the `npx` command with the `mcp-remote@latest` argument:

MCP client configuration for MCP portals

```
{  "mcpServers": {    "example-mcp-server": {      "command": "npx",      "args": [        "-y",        "mcp-remote@latest",        "https://<subdomain>.<domain>.com/mcp"      ]    }  }}
```

We do not recommend using the `serverURL` parameter since it may cause issues with portal session creation and management.

### Portal homepage

When users visit the portal domain (`https://<subdomain>.<domain>/`) in a browser, the portal displays a homepage with connection details and setup instructions.

Note

Do not visit the MCP endpoint URL (`https://<subdomain>.<domain>/mcp`) directly in a browser. The `/mcp` path is intended for MCP clients only and will return an `invalid token` error if accessed in a browser.

The homepage shows:

* The portal name and your organization branding (if configured in Cloudflare Access)
* The MCP endpoint URL with a copy button
* Per-client connection instructions for Claude Desktop, Workers AI Playground, OpenCode, Windsurf, and other MCP clients with OS-specific file paths

Authenticated users see their email address and a **Sign out** button in the session bar. Users who are not authenticated can still view the homepage and connection instructions.

### Sign out of a portal

To end a portal session, select **Sign out** from the [portal homepage](#portal-homepage) (`https://<subdomain>.<domain>/`). The sign-out flow:

1. Revokes all portal-level OAuth grants for your user.
2. Deletes all upstream MCP server OAuth states associated with your session.
3. Redirects through Cloudflare Access logout.

After sign-out, the portal displays a confirmation page with a summary of the revoked sessions. To reconnect, visit the portal homepage and authenticate again.

### Connect with a service token

You can connect to an MCP portal using an [Access service token](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/) for machine-to-machine access. Service tokens bypass the browser-based OAuth flow and authenticate using the `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers.

A service token session is authorized twice: once at the portal URL, and once for each upstream MCP server it tries to reach through the portal. Both checks need a matching [Service Auth policy](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/#service-auth).

#### Required configuration

| Where                             | Policy action | Include rule       | Purpose                                                                        |
| --------------------------------- | ------------- | ------------------ | ------------------------------------------------------------------------------ |
| Portal Access application         | Service Auth  | Your service token | Lets the bot connect to the portal URL.                                        |
| Each linked MCP server Access app | Service Auth  | Your service token | Lets the bot see and call that server's tools through the portal.              |
| Server's portal mapping           | n/a           | n/a                | **Require user auth** must be **off** so the portal uses the admin credential. |

Note

**Require user auth** in the dashboard maps to the `on_behalf` field on the portal-server mapping in the API and Terraform. For each linked server you want a service token to reach, set `on_behalf` to `false`. Servers with `on_behalf: true` are excluded from service token sessions because they require a per-user OAuth grant that a service token cannot provide.

If a linked MCP server does not have a Service Auth policy matching the token, that server is hidden from the bot's tool list.

#### Set up a service token connection

1. [Create a service token](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/#create-a-service-token) in your Zero Trust account.
2. Open the portal's Access application and add a Service Auth policy that includes the service token.
3. For each upstream MCP server you want the bot to reach:  
  1. Open the server's Access application and add a Service Auth policy that includes the same service token.
  2. Open the portal and edit the server. Turn **Require user auth** off so the portal uses the [admin credential](#reauthenticate-the-mcp-server) for that server.
4. Connect from your MCP client with the service token headers.

For a CLI client, set the headers directly:

Terminal window

```
curl https://<subdomain>.<domain>/mcp \  -H "CF-Access-Client-Id: <CLIENT_ID>" \  -H "CF-Access-Client-Secret: <CLIENT_SECRET>"
```

For `mcp-remote`, pass the headers with `--header`:

MCP client configuration for service token connections

```
{  "mcpServers": {    "example-portal": {      "command": "npx",      "args": [        "-y",        "mcp-remote@latest",        "https://<subdomain>.<domain>/mcp",        "--header",        "CF-Access-Client-Id: <CLIENT_ID>",        "--header",        "CF-Access-Client-Secret: <CLIENT_SECRET>"      ]    }  }}
```

Note

Service tokens do not support per-user OAuth with upstream MCP servers. The portal uses the [admin credential](#reauthenticate-the-mcp-server) for every upstream request made by a service token session. Servers with **Require user auth** turned on are excluded from service token sessions because they require a per-user OAuth grant that a service token cannot provide.

### Device authentication

MCP server portals require a browser-based authentication flow. [Device authentication](https://developers.cloudflare.com/cloudflare-one/team-and-resources/devices/cloudflare-one-client/) (picking up identity from the Cloudflare One Client without a browser redirect) is not currently supported for MCP portals. Users must complete the Access login flow in a browser when first connecting.

## Optimize context

MCP server portals support context optimization options that reduce how many tokens tool definitions consume in the model's context window. These options are useful when a portal aggregates many MCP servers or servers that expose a large number of tools.

To use context optimization, append the `optimize_context` query parameter to your portal URL when connecting from an MCP client.

### Minimize tools

The `minimize_tools` option strips tool descriptions and input schemas from all upstream tools, leaving only their names. The portal exposes a special `query` tool that agents use to search and retrieve full tool definitions on demand. Agents can discover tools without loading all definitions upfront.

This option provides up to 5x savings in token usage, though querying tool definitions before use adds a small amount of overhead.

To connect with `minimize_tools`, use the following portal URL:

```
https://<subdomain>.<domain>/mcp?optimize_context=minimize_tools
```

For MCP clients with server configuration files:

MCP client configuration with minimize\_tools

```
{  "mcpServers": {    "example-portal": {      "command": "npx",      "args": [        "-y",        "mcp-remote@latest",        "https://<subdomain>.<domain>/mcp?optimize_context=minimize_tools"      ]    }  }}
```

### Search and execute

The `search_and_execute` option hides all upstream tools and exposes only two tools to the agent: `query` and `execute`. The `query` tool searches and retrieves tool definitions. The `execute` tool runs the upstream tools. The generated code runs in an isolated [Dynamic Worker](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) environment, which keeps authentication credentials and environment variables out of the model context.

This option reduces the initial token cost of portal tools to a small constant, regardless of how many tools are available. However, the agent becomes fully reliant on `query` to discover tools before it can call them.

To connect with `search_and_execute`, use the following portal URL:

```
https://<subdomain>.<domain>/mcp?optimize_context=search_and_execute
```

For MCP clients with server configuration files:

MCP client configuration with search\_and\_execute

```
{  "mcpServers": {    "example-portal": {      "command": "npx",      "args": [        "-y",        "mcp-remote@latest",        "https://<subdomain>.<domain>/mcp?optimize_context=search_and_execute"      ]    }  }}
```

For more information on the Code Mode pattern behind `search_and_execute`, refer to [Code Mode](https://developers.cloudflare.com/agents/tools/codemode/).

## Manage portal sessions

Once connected to a portal, users can manage their upstream MCP server sessions without leaving their MCP client. The portal uses [MCP elicitations ↗](https://modelcontextprotocol.io/specification/2025-03-26/server/elicitation) to provide a server selection page where you can enable or disable servers, log out of individual servers, and reauthenticate.

### Return to the server selection page

To manage your server connections during an active session, ask your AI agent to take you back to the server selection page. For example, prompt your agent with:

> Take me back to the server selection page.

The portal returns an authorization URL. Open this URL in your web browser to access the server selection page:

```
https://<subdomain>.<domain>/authorize?elicitationId=<ELICITATION_ID>
```

From this page you can:

* **Enable or disable servers** — Toggle individual upstream MCP servers on or off. Disabling a server removes its tools from the active session, which reduces context window usage.
* **Log out and reauthenticate** — Log out of a server and log back in if you need to change which data the server has access to. For example, you may need to reauthenticate with different permissions.

### Enable or disable a server inline

You can also enable or disable a specific server directly from your MCP client without visiting the server selection page. For example:

> Enable the wiki server.

> Disable my Jira server.

The portal toggles the server and updates the active tool list immediately. Disabling a server removes its tools from the session, which reduces context window usage.

### Reauthenticate a server

When an upstream MCP server token expires, the portal prompts you to reauthenticate from within your MCP client. Open the provided URL in your browser and complete the login to restore the session.

If your MCP client does not display the reauthentication prompt, you can manually clear cached credentials:

Note

This command clears credentials for all MCP servers using `mcp-remote@latest`, not just MCP portals.

Terminal window

```
rm -rf ~/.mcp-auth
```

After clearing credentials, reconnect to the portal from your MCP client.

### Authorize new servers

When an admin adds a new upstream MCP server to a portal, the portal automatically prompts connected users to authorize the new server. The portal batches admin changes and redirects you to the authorization flow once, rather than interrupting for each individual server update.

## View portal logs

Portal logs allow you to monitor user activity through an MCP server portal. You can view logs on a per-portal or per-server basis.

1. In the [Cloudflare dashboard ↗](https://dash.cloudflare.com/), go to **Zero Trust** \> **Access controls** \> **AI controls**.
2. Find the portal or server that you want to view logs for, then select the three dots > **Edit**.
3. Select **Logs**.

### Log fields

| Field      | Description                                         |
| ---------- | --------------------------------------------------- |
| Time       | Date and time of the request                        |
| Status     | Whether the server successfully returned a response |
| Server     | Name of the MCP server that handled the request     |
| Capability | The tool used to process the request                |
| Duration   | Processing time for the request in milliseconds     |

### Export logs with Logpush

Availability

Only available on Enterprise plans.

You can automatically export MCP portal logs to third-party storage destinations or security information and event management (SIEM) tools using [Logpush](https://developers.cloudflare.com/logs/logpush/). This allows you to integrate with your existing security workflows and retain logs for as long as your business requires.

To set up a Logpush job for MCP portal logs, refer to [Logpush integration](https://developers.cloudflare.com/cloudflare-one/insights/logs/logpush/). For a list of available log fields, refer to [MCP portal logs](https://developers.cloudflare.com/logs/logpush/logpush-job/datasets/account/mcp%5Fportal%5Flogs/).

## Known limitations

MCP server portals have the following known limitations:

* **Only remote HTTP MCP servers are supported.** MCP servers that use [stdio transport only ↗](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) (for example, `github/github-mcp-server`) do not expose a remote HTTP endpoint and cannot be added to an MCP server portal. To use a stdio-only server, you must self-host it behind an HTTP endpoint and authenticate with a [bearer token or custom headers](#create-an-mcp-server).
* **Some MCP servers block proxy-based clients.** Certain MCP servers reject requests from proxy-based clients like MCP server portals, returning a `403` error on the registration endpoint. These servers are not compatible with MCP server portals until those providers add Cloudflare as a supported MCP client.
* **Not all MCP servers support OAuth dynamic client registration.** MCP servers that do not support [OAuth dynamic client registration ↗](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration) cannot use the portal's OAuth authentication flow. For these servers, select **Custom Headers** as the authentication method and provide static credentials (for example, API keys or personal access tokens) instead.
* **Admin OAuth tokens can expire silently.** The admin credential used to [authenticate an MCP server](#reauthenticate-the-mcp-server) is subject to the upstream provider's token expiration policy. When the token expires, the server status changes to **Error** or **Sync Required** and the server will not appear in the portal for end users. Admins are not notified when this happens. Periodically check the [server status](#server-status) and [reauthenticate](#reauthenticate-the-mcp-server) servers that show an error.
* **Each portal supports up to 40 MCP servers.** If you need to aggregate more than 40 servers into a single portal, contact your Cloudflare account team to request a higher limit. The dashboard displays a warning as you approach the limit.

## Policy limitations

MCP servers use a dedicated Access application type (_mcp_) that does not support the following Access policy features when the server is authorized through a portal.

* **[Independent MFA](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/mfa-requirements/#independent-mfa)** — Users will not be prompted to perform MFA through Cloudflare Access when authorizing a server, regardless of whether MFA global enforcement is enabled or whether an MFA policy is assigned to the server.
* **[Purpose justification](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/require-purpose-justification/)** — Users will not be prompted to provide a purpose justification when authorizing a server.
* **[Temporary authentication](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/temporary-auth/)** — Users will not be prompted to request access and approvers will not receive approval requests when a user authorizes a server.

These limitations only apply to servers that are being authorized through a portal. Access policy selectors such as Emails, Groups, Country, and Device Posture Checks will be enforced.

Independent MFA, purpose justification, and temporary authentication will be enforced for servers that are not authorized through a portal.

## Troubleshooting

### After authenticating to the portal, my user receives the error `No allowed servers available, check your Zero Trust Policies`.

1. An MCP portal and server must both have an attached Access policy. Ensure that all MCP servers assigned to the portal have their own associated policy.
2. The server's admin authentication may be expired. Check that the [server's status](#server-status) is **Ready**. If the status shows **Error** or **Sync Required**, [reauthenticate the server](#reauthenticate-the-mcp-server).

### The portal URL does not prompt for authentication when it is added to an MCP client.

1. Verify that the portal has an assigned Access policy.
2. Verify that the portal URL does not have any applied [Workers](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/), [Page Rules](https://developers.cloudflare.com/rules/page-rules/manage/), [custom hostname](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/) definitions, or any other configuration that may interfere with its ability to connect to the MCP client.

### The portal returns a `522` error.

A `522` error indicates that Cloudflare cannot reach the portal's origin. This typically means the DNS record for the portal hostname is missing or misconfigured.

1. Verify that a CNAME record exists for your portal subdomain pointing to `gateway.agents.cloudflare.com`.
2. Ensure the CNAME record is proxied (orange-clouded) through Cloudflare.
3. If you created the portal using the [API](#manage-portals-via-api) or the [Terraform provider](#configure-via-terraform), you must create the DNS record separately. Unlike the dashboard, the API and Terraform provider do not auto-create DNS records.

### An MCP server is stuck in `Waiting` status.

The `Waiting` status means Cloudflare is attempting to connect to the upstream MCP server and fetch its tools and prompts. If the server stays in this status:

1. Verify that the upstream MCP server URL is correct and the server is reachable.
2. Check that the upstream server supports [Streamable HTTP ↗](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/transports/#streamable-http) or SSE transport. The portal will attempt multiple connection strategies automatically.
3. If the server requires authentication, verify that the admin credentials are valid by [reauthenticating the server](#reauthenticate-the-mcp-server).
4. Select the three dots > **Sync capabilities** to manually retry the connection.

### An MCP server shows `Stale` status.

A `Stale` status means the admin credential for the server could not be refreshed during the last synchronization attempt. The server's tools may still work for users who have their own OAuth tokens (servers with **Require user auth** turned on), but the admin credential needs to be refreshed.

To resolve this, [reauthenticate the server](#reauthenticate-the-mcp-server) with valid admin credentials.

### Tool calls fail with an `unauthorized` error.

1. If the server uses per-user OAuth (**Require user auth** is turned on), the user's OAuth token may have expired. Ask the user to [reauthenticate the server](#reauthenticate-a-server) from their MCP client.
2. If the server uses admin credentials, check the [server status](#server-status). A status of **Error** or **Sync Required** indicates the admin credential needs to be refreshed.
3. If the user recently changed permissions on the upstream service (for example, revoked OAuth scopes), they will need to reauthenticate.

### OAuth authentication fails with a redirect URI error when connecting to an upstream MCP server.

Errors such as `invalid_redirect_uri`, `invalid_client_metadata`, or `Redirect URI not allowed` indicate that the upstream MCP server rejected the callback URL that the portal registered during the OAuth flow. Refer to [Upstream OAuth callback URL](#upstream-oauth-callback-url) for background on how the callback URL is determined.

1. By default, the upstream provider must allowlist `https://<your-portal-hostname>/servers-callback` as a redirect URI (for example, `https://my-portal.example.com/servers-callback`). OAuth providers typically exact-match the full URI including path. Contact the upstream MCP server vendor if you do not control the allowlist.
2. If the portal is configured to use the [shared Cloudflare callback URL](#shared-cloudflare-callback-url-opt-in), the upstream provider must instead allowlist `https://oauth-callbacks.cloudflareaccess.com/cdn-cgi/access/outbound-oauth-callback`.

### Tool calls fail when Gateway routing is turned on.

1. Verify that the upstream MCP server supports Streamable HTTP transport. SSE transport is not supported through Gateway.
2. If the upstream server URL ends in `/sse`, the portal automatically attempts to connect using Streamable HTTP on a `/mcp` path instead. If the server does not support this, the connection will fail.
3. Check [Gateway HTTP logs](https://developers.cloudflare.com/cloudflare-one/insights/logs/dashboard-logs/gateway-logs/) for DLP block events. If a DLP policy is blocking the traffic, the portal returns an error to the MCP client with the DLP rule ID.

### Users cannot connect with `mcp-remote` or similar tools.

1. Ensure you are using the latest version of `mcp-remote`. Run `npx -y mcp-remote@latest` to update.
2. Use the `command` and `args` format in your MCP client configuration, not the `serverURL` parameter. The `serverURL` parameter may cause issues with portal session creation.
3. If authentication fails repeatedly, clear cached credentials by running `rm -rf ~/.mcp-auth` and reconnecting.

### The portal homepage shows the wrong name or domain.

The portal homepage displays your Access organization name and branding. If the displayed name is incorrect:

1. In the [Cloudflare dashboard ↗](https://dash.cloudflare.com/), go to **Zero Trust** \> **Settings** \> **General** \> **Team name**.
2. Update your team name. The change will take effect the next time a user visits the portal homepage.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/mcp-portals/#page","headline":"MCP server portals · Cloudflare One docs","description":"MCP server portals in Access.","url":"https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/mcp-portals/","inLanguage":"en","image":"https://developers.cloudflare.com/zt-preview.png","dateModified":"2026-06-29","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/cloudflare-one/","name":"Cloudflare One"}},{"@type":"ListItem","position":3,"item":{"@id":"/cloudflare-one/access-controls/","name":"Access controls"}},{"@type":"ListItem","position":4,"item":{"@id":"/cloudflare-one/access-controls/ai-controls/","name":"AI controls"}},{"@type":"ListItem","position":5,"item":{"@id":"/cloudflare-one/access-controls/ai-controls/mcp-portals/","name":"MCP server portals"}}]}
```

---

---
title: Cloudflare's own MCP servers
description: Connect to Cloudflare's managed remote MCP servers to read configurations, manage services, and automate actions across your account.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Cloudflare's own MCP servers

Cloudflare runs a catalog of managed remote MCP servers which you can connect to using OAuth on clients like [Claude ↗](https://modelcontextprotocol.io/quickstart/user), [Windsurf ↗](https://docs.windsurf.com/windsurf/cascade/mcp), our own [AI Playground ↗](https://playground.ai.cloudflare.com/) or any [SDK that supports MCP ↗](https://github.com/cloudflare/agents/tree/main/packages/agents/src/mcp).

These MCP servers allow your MCP client to read configurations from your account, process information, make suggestions based on data, and even make those suggested changes for you. All of these actions can happen across Cloudflare's many services including application development, security and performance. They support both the `streamable-http` transport via `/mcp` and the `sse` transport (deprecated) via `/sse`.

## Cloudflare API MCP server

The [Cloudflare API MCP server ↗](https://github.com/cloudflare/mcp) provides access to the entire [Cloudflare API](https://developers.cloudflare.com/api/) — over 2,500 endpoints across DNS, Workers, R2, Zero Trust, and every other product — through just two tools: `search()` and `execute()`.

It uses [the search-and-execute Code Mode pattern](https://developers.cloudflare.com/agents/model-context-protocol/codemode/#search-and-execute), a technique where the model writes JavaScript against a typed representation of the OpenAPI spec and the Cloudflare API client, rather than loading individual tool definitions for each endpoint. The generated code runs inside an isolated [Dynamic Worker](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) sandbox.

This approach uses approximately 1,000 tokens regardless of how many API endpoints exist. An equivalent MCP server that exposed every endpoint as a native tool would consume over 1 million tokens — more than the entire context window of most foundation models.

| Approach                          | Tools | Token cost  |
| --------------------------------- | ----- | ----------- |
| Native MCP (full schemas)         | 2,594 | \~1,170,000 |
| Native MCP (required params only) | 2,594 | \~244,000   |
| Code Mode                         | 2     | \~1,000     |

### Connect to the Cloudflare API MCP server

Add the following configuration to your MCP client:

```
{  "mcpServers": {    "cloudflare-api": {      "url": "https://mcp.cloudflare.com/mcp"    }  }}
```

When you connect, you will be redirected to Cloudflare to authorize via OAuth and select the permissions to grant to your agent.

For CI/CD or automation, you can create a [Cloudflare API token ↗](https://dash.cloudflare.com/profile/api-tokens) with the permissions you need and pass it as a bearer token in the `Authorization` header. Both user tokens and account tokens are supported.

For more information, refer to the [Cloudflare MCP repository ↗](https://github.com/cloudflare/mcp).

### Install via agent and IDE plugins

You can install the [Cloudflare Skills plugin ↗](https://github.com/cloudflare/skills), which bundles the Cloudflare MCP servers alongside contextual skills and slash commands for building on Cloudflare. The plugin works with any agent that supports the Agent Skills standard, including Claude Code, OpenCode, OpenAI Codex, and Pi.

#### Claude Code

Install using the [plugin marketplace ↗](https://code.claude.com/docs/en/discover-plugins#add-from-github):

```
/plugin marketplace add cloudflare/skills
```

#### Cursor

Install from the **Cursor Marketplace**, or add manually via **Settings** \> **Rules** \> **Add Rule** \> **Remote Rule (Github)** with `cloudflare/skills`.

#### npx skills

Install using the [npx skills ↗](https://skills.sh) CLI:

Terminal window

```
npx skills add https://github.com/cloudflare/skills
```

#### Clone or copy

Clone the [cloudflare/skills ↗](https://github.com/cloudflare/skills) repository and copy the skill folders into the appropriate directory for your agent:

| Agent        | Skill directory             | Docs                                                                                                   |
| ------------ | --------------------------- | ------------------------------------------------------------------------------------------------------ |
| Claude Code  | \~/.claude/skills/          | [Claude Code skills ↗](https://code.claude.com/docs/en/skills)                                         |
| Cursor       | \~/.cursor/skills/          | [Cursor skills ↗](https://cursor.com/docs/context/skills)                                              |
| OpenCode     | \~/.config/opencode/skills/ | [OpenCode skills ↗](https://opencode.ai/docs/skills/)                                                  |
| OpenAI Codex | \~/.codex/skills/           | [OpenAI Codex skills ↗](https://developers.openai.com/codex/skills/)                                   |
| Pi           | \~/.pi/agent/skills/        | [Pi coding agent skills ↗](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#skills) |

## Product-specific MCP servers

In addition to the Cloudflare API MCP server, Cloudflare provides product-specific MCP servers for targeted use cases:

| Server Name                                                                                                               | Description                                                                                     | Server URL                                   |
| ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------- |
| [Documentation server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/docs-vectorize)               | Get up to date reference information on Cloudflare                                              | https://docs.mcp.cloudflare.com/mcp          |
| [Workers Bindings server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/workers-bindings)          | Build Workers applications with storage, AI, and compute primitives                             | https://bindings.mcp.cloudflare.com/mcp      |
| [Workers Builds server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/workers-builds)              | Get insights and manage your Cloudflare Workers Builds                                          | https://builds.mcp.cloudflare.com/mcp        |
| [Observability server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/workers-observability)        | Debug and get insight into your application's logs and analytics                                | https://observability.mcp.cloudflare.com/mcp |
| [Radar server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/radar)                                | Get global Internet traffic insights, trends, URL scans, and other utilities                    | https://radar.mcp.cloudflare.com/mcp         |
| [Container server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/sandbox-container)                | Spin up a sandbox development environment                                                       | https://containers.mcp.cloudflare.com/mcp    |
| [Browser Run server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/browser-rendering)              | Fetch web pages, convert them to markdown and take screenshots                                  | https://browser.mcp.cloudflare.com/mcp       |
| [Logpush server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/logpush)                            | Get quick summaries for Logpush job health                                                      | https://logs.mcp.cloudflare.com/mcp          |
| [AI Gateway server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/ai-gateway)                      | Search your logs, get details about the prompts and responses                                   | https://ai-gateway.mcp.cloudflare.com/mcp    |
| [AI Search server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/autorag)                          | List and search documents on your AI Searches                                                   | https://autorag.mcp.cloudflare.com/mcp       |
| [Audit Logs server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/auditlogs)                       | Query audit logs and generate reports for review                                                | https://auditlogs.mcp.cloudflare.com/mcp     |
| [DNS Analytics server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/dns-analytics)                | Optimize DNS performance and debug issues based on current set up                               | https://dns-analytics.mcp.cloudflare.com/mcp |
| [Digital Experience Monitoring server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/dex-analysis) | Get quick insight on critical applications for your organization                                | https://dex.mcp.cloudflare.com/mcp           |
| [Cloudflare One CASB server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/cloudflare-one-casb)    | Quickly identify any security misconfigurations for SaaS applications to safeguard users & data | https://casb.mcp.cloudflare.com/mcp          |
| [GraphQL server ↗](https://github.com/cloudflare/mcp-server-cloudflare/tree/main/apps/graphql/)                           | Get analytics data using Cloudflare's GraphQL API                                               | https://graphql.mcp.cloudflare.com/mcp       |
| [Agents SDK Documentation server ↗](https://github.com/cloudflare/agents/tree/main/site/agents)                           | Token-efficient search of the Cloudflare Agents SDK documentation                               | https://agents.cloudflare.com/mcp            |

Check the [GitHub page ↗](https://github.com/cloudflare/mcp-server-cloudflare) to learn how to use Cloudflare's remote MCP servers with different MCP clients.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/#page","headline":"Cloudflare's own MCP servers · Cloudflare Agents docs","description":"Connect to Cloudflare's managed remote MCP servers to read configurations, manage services, and automate actions across your account.","url":"https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/cloudflare/","name":"Cloudflare"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/cloudflare/servers-for-cloudflare/","name":"Cloudflare's own MCP servers"}}]}
```

---

---
title: Cloudflare Community MCP Server
description: Learn how to use the Cloudflare Community MCP server to search topics, read posts, and filter content.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Cloudflare Community MCP Server

The MCP server for the [Cloudflare Community forum ↗](https://community.cloudflare.com) lets AI agents search topics, read posts, look up users, and filter content.

The server is powered by [@discourse/mcp ↗](https://www.npmjs.com/package/@discourse/mcp), the official Discourse MCP server.

## Install

Terminal window

```
npx @discourse/mcp@latest
```

## Configure

### OpenCode

Add to `~/.config/opencode/opencode.jsonc` inside the `"mcp"` block:

```
"discourse": {  "type": "local",  "command": ["npx", "-y", "@discourse/mcp@latest"],  "enabled": true}
```

### Claude Desktop

Add to `claude_desktop_config.json`:

```
{  "mcpServers": {    "discourse": {      "command": "npx",      "args": ["-y", "@discourse/mcp@latest"]    }  }}
```

### Cursor

Add to `.cursor/mcp.json` in your project root:

```
{  "mcpServers": {    "discourse": {      "command": "npx",      "args": ["-y", "@discourse/mcp@latest"]    }  }}
```

## Connect to the Cloudflare Community

After configuring your client, use the `discourse_select_site` tool with:

```
https://community.cloudflare.com
```

No API key is needed for reading public data. An API key is only required for write operations (posting, moderation).

## Available tools

| Tool                         | Description                              |
| ---------------------------- | ---------------------------------------- |
| discourse\_select\_site      | Connect to community.cloudflare.com      |
| discourse\_search            | Full-text search across topics and posts |
| discourse\_filter\_topics    | Filter by category, tags, status, dates  |
| discourse\_read\_topic       | Read a topic's posts and metadata        |
| discourse\_read\_post        | Read a specific post                     |
| discourse\_get\_user         | Look up a user's profile                 |
| discourse\_list\_user\_posts | List posts by a user                     |

## Example usage

Once connected, you can ask your AI assistant things like:

* "Search the Cloudflare community for topics about Error 522"
* "Find unanswered topics in the SSL category from the last 3 days"
* "Read topic 42325 and summarize the issue"
* "Show me recent replies from user sandro"

## Machine-readable discovery

AI agents can automatically discover the MCP server through these endpoints on community.cloudflare.com:

* [/.well-known/mcp.json ↗](https://community.cloudflare.com/.well-known/mcp.json) — MCP Server Card
* [/llms.txt ↗](https://community.cloudflare.com/llms.txt) — LLMs.txt with server info and install instructions
* [/.well-known/agent.json ↗](https://community.cloudflare.com/.well-known/agent.json) — A2A Agent Card

## Related resources

* [Setup guide with detailed configuration instructions ↗](https://community.cloudflare.com/mcp)
* [The official npm: @discourse/mcp package ↗](https://www.npmjs.com/package/@discourse/mcp)
* [Model Context Protocol specification ↗](https://modelcontextprotocol.io)
* [Building AI agents on Cloudflare](https://developers.cloudflare.com/agents/)
* [Cloudflare Community forum ↗](https://community.cloudflare.com)

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/community-mcp-server/#page","headline":"Cloudflare Community MCP Server · Cloudflare Agents docs","description":"Learn how to use the Cloudflare Community MCP server to search topics, read posts, and filter content.","url":"https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/community-mcp-server/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/cloudflare/","name":"Cloudflare"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/cloudflare/servers-for-cloudflare/","name":"Cloudflare's own MCP servers"}},{"@type":"ListItem","position":6,"item":{"@id":"/agents/model-context-protocol/cloudflare/servers-for-cloudflare/community-mcp-server/","name":"Cloudflare Community MCP Server"}}]}
```

---

---
title: Code Mode MCP server patterns
description: Understand single-code-tool and search-and-execute patterns for exposing tools and large APIs through MCP.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Code Mode MCP server patterns

A Code Mode MCP server lets any Model Context Protocol (MCP) client use model-written code without providing its own sandbox. The MCP server exposes code execution as its tool interface and runs generated JavaScript in an isolated Worker.

Code Mode MCP servers follow two patterns:

| Pattern            | MCP tools       | Model-facing API                      | Use when                                                               |
| ------------------ | --------------- | ------------------------------------- | ---------------------------------------------------------------------- |
| Single code tool   | code            | Typed methods for every upstream tool | You already have an MCP server with a manageable set of tools.         |
| Search and execute | search, execute | OpenAPI document and request function | You have a large API whose complete schema should stay out of context. |

Both patterns let generated code compose operations and keep intermediate results outside the model context. They differ in how the model discovers available operations.

## Single code tool

The single-tool pattern wraps an existing MCP server with `codeMcpServer()`. Instead of advertising each upstream tool separately, the server advertises one `code` tool.

The `code` tool description contains generated TypeScript definitions for every upstream tool. The model writes JavaScript against a `codemode` namespace:

JavaScript

```
async () => {  const projects = await codemode.list_projects({ status: "active" });  const tasks = [];
  for (const project of projects) {    tasks.push(...(await codemode.list_tasks({ projectId: project.id })));  }
  return tasks.filter((task) => task.status === "blocked");};
```

The MCP client makes one outer tool call. Inside the sandbox, the code can make dependent upstream calls, filter intermediate data, and return only the final result.

This pattern works well when the generated type declarations fit comfortably in the `code` tool description. The model receives those declarations when it loads the MCP tool.

To implement this pattern, refer to [Build a single-tool Code Mode MCP server](https://developers.cloudflare.com/agents/model-context-protocol/guides/build-codemode-mcp-server/).

## Search and execute

A large API can have thousands of operations. Including every operation in one tool description would still consume substantial context. The search-and-execute pattern separates capability discovery from authenticated API calls.

The server exposes two MCP tools:

* `search` runs generated code against an OpenAPI document. It returns only the operations, parameters, or schemas needed for the task.
* `execute` runs generated code with an authenticated request function. It can call the selected operations, compose responses, and return a focused result.

The model first calls `search` with code such as:

JavaScript

```
async () => {  const spec = await codemode.spec();  return Object.entries(spec.paths)    .filter(([path]) => path.includes("/rulesets"))    .map(([path, operations]) => ({      path,      methods: Object.keys(operations),    }));};
```

The complete OpenAPI document remains inside the sandbox. Only the returned subset enters the model context.

After selecting an operation, the model calls `execute`:

JavaScript

```
async () => {  const response = await codemode.request({    method: "GET",    path: `/zones/${zoneId}/rulesets`,  });
  return response.result.map(({ id, name, phase }) => ({ id, name, phase }));};
```

Authentication stays in the host request callback. The generated code receives a request function, not the credential.

The [Cloudflare API MCP server](https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/) uses this pattern to expose the Cloudflare API through `search` and `execute`. For the design rationale and context savings, refer to [Code Mode: give agents an entire API in 1,000 tokens ↗](https://blog.cloudflare.com/code-mode-mcp/).

To implement this pattern, refer to [Build a search and execute MCP server](https://developers.cloudflare.com/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server/).

## Sandbox and authorization boundary

Model-written code runs in an isolated Worker. Direct outbound network access is blocked by default. Generated code reaches external systems only through upstream MCP tools or a host-provided request callback.

Code execution does not replace authorization. Enforce permissions and any required approval inside upstream tool handlers or the host request callback before applying side effects. Do not expose credentials through tool results or OpenAPI documents.

## Choose a pattern

Use `codeMcpServer()` when an existing MCP server already defines the operations and schemas the model needs. Use `openApiMcpServer()` when a large OpenAPI catalog needs progressive discovery and a fixed model-context footprint.

[ Build a single-tool server ](https://developers.cloudflare.com/agents/model-context-protocol/guides/build-codemode-mcp-server/) Wrap an existing MCP server with one code tool. 

[ Build a search and execute server ](https://developers.cloudflare.com/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server/) Publish a large OpenAPI service through progressive discovery.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/codemode/#page","headline":"Code Mode MCP server patterns · Cloudflare Agents docs","description":"Understand single-code-tool and search-and-execute patterns for exposing tools and large APIs through MCP.","url":"https://developers.cloudflare.com/agents/model-context-protocol/codemode/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI","MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/codemode/","name":"Code Mode MCP server patterns"}}]}
```

---

---
title: Build a single-tool Code Mode MCP server
description: Replace an MCP server's individual tools with one sandboxed Code Mode tool on Cloudflare Workers.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Build a single-tool Code Mode MCP server

Use `codeMcpServer()` to wrap an existing Model Context Protocol (MCP) server. MCP clients receive one `code` tool instead of every upstream tool.

The `code` tool contains generated type definitions for the upstream tools. Model-written JavaScript can call several tools, process their results, and return one focused value.

Warning

Code Mode is experimental and may have breaking changes. Use caution in production.

## Prerequisites

You need a Cloudflare Workers project and an existing `McpServer`.

## Wrap the server

1. Install Code Mode and the MCP dependencies:  
 npm  yarn  pnpm  bun  
```  
npm i @cloudflare/codemode agents @modelcontextprotocol/sdk zod  
```  
```  
yarn add @cloudflare/codemode agents @modelcontextprotocol/sdk zod  
```  
```  
pnpm add @cloudflare/codemode agents @modelcontextprotocol/sdk zod  
```  
```  
bun add @cloudflare/codemode agents @modelcontextprotocol/sdk zod  
```
2. Add a Worker Loader binding and the `nodejs_compat` compatibility flag:

  * [  wrangler.jsonc ](#tab-panel-6021)
  * [  wrangler.toml ](#tab-panel-6022)  
JSONC  
```  
{  "$schema": "./node_modules/wrangler/config-schema.json",  "name": "codemode-mcp-server",  "main": "src/server.ts",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": [    "nodejs_compat"  ],  "worker_loaders": [    {      "binding": "LOADER"    }  ]}  
```  
TOML  
```  
name = "codemode-mcp-server"main = "src/server.ts"# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = ["nodejs_compat"]  
[[worker_loaders]]binding = "LOADER"  
```
3. Create the upstream server and pass it to `codeMcpServer()`:

  * [  JavaScript ](#tab-panel-6023)
  * [  TypeScript ](#tab-panel-6024)  
src/server.js  
```  
import { DynamicWorkerExecutor } from "@cloudflare/codemode";import { codeMcpServer } from "@cloudflare/codemode/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { createMcpHandler } from "agents/mcp";import { z } from "zod";  
function createOrderServer() {  const server = new McpServer({    name: "orders",    version: "1.0.0",  });  
  server.registerTool(    "get_order",    {      description: "Get an order by ID",      inputSchema: {        orderId: z.string().describe("Order ID"),      },    },    async ({ orderId }) => ({      structuredContent: {        id: orderId,        status: "processing",      },      content: [        {          type: "text",          text: JSON.stringify({ id: orderId, status: "processing" }),        },      ],    }),  );  
  return server;}  
export default {  async fetch(request, env, ctx) {    const upstream = createOrderServer();    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });    const server = await codeMcpServer({      server: upstream,      executor,    });  
    return createMcpHandler(server, { route: "/mcp" })(request, env, ctx);  },};  
```  
src/server.ts  
```  
import { DynamicWorkerExecutor } from "@cloudflare/codemode";import { codeMcpServer } from "@cloudflare/codemode/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { createMcpHandler } from "agents/mcp";import { z } from "zod";  
function createOrderServer() {  const server = new McpServer({    name: "orders",    version: "1.0.0",  });  
  server.registerTool(    "get_order",    {      description: "Get an order by ID",      inputSchema: {        orderId: z.string().describe("Order ID"),      },    },    async ({ orderId }) => ({      structuredContent: {        id: orderId,        status: "processing",      },      content: [        {          type: "text",          text: JSON.stringify({ id: orderId, status: "processing" }),        },      ],    }),  );  
  return server;}  
export default {  async fetch(request, env, ctx): Promise<Response> {    const upstream = createOrderServer();    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });    const server = await codeMcpServer({      server: upstream,      executor,    });  
    return createMcpHandler(server, { route: "/mcp" })(      request,      env,      ctx,    );  },} satisfies ExportedHandler<Env>;  
```
4. Deploy the Worker:  
 npm  yarn  pnpm  
```  
npx wrangler deploy  
```  
```  
yarn wrangler deploy  
```  
```  
pnpm wrangler deploy  
```
5. In an MCP client, connect to `https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/mcp`. Verify that the server exposes one tool named `code`.

The model can use the generated `codemode` namespace inside the `code` tool:

JavaScript

```
async () => {  const order = await codemode.get_order({ orderId: "order-123" });  return { id: order.id, status: order.status };};
```

When an upstream tool returns `structuredContent`, Code Mode exposes that value directly. Text-only content is joined and parsed as JSON when possible. Upstream MCP errors become exceptions that model-written code can catch. Mixed text and binary content remains in its MCP result structure.

If you provide a custom `description`, use `{{types}}` where the generated TypeScript declarations should appear. Use `{{example}}` where the SDK should insert an example call based on the first upstream MCP tool. Both placeholders are optional.

## Protect upstream operations

`codeMcpServer()` does not provide durable approval for each upstream tool call. It invokes upstream handlers from inside the outer `code` tool.

Enforce authorization and any required per-operation approval in each upstream handler before applying side effects. Do not include credentials in tool results.

`DynamicWorkerExecutor` blocks external `fetch()` and `connect()` calls by default. Generated code can reach external systems only through the upstream MCP tools.

## Limit results

Model-written code can select, map, aggregate, or paginate upstream data before returning. This prevents large intermediate results from entering the model context.

The publisher limits the final MCP response to approximately 6,000 estimated tokens. A larger response is cut off and includes a `--- TRUNCATED ---` marker. This does not reduce work already performed by upstream tools.

To publish an OpenAPI service with separate `search` and `execute` tools, refer to [Build a search and execute MCP server](https://developers.cloudflare.com/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server/).

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/guides/build-codemode-mcp-server/#page","headline":"Build a single-tool Code Mode MCP server · Cloudflare Agents docs","description":"Replace an MCP server's individual tools with one sandboxed Code Mode tool on Cloudflare Workers.","url":"https://developers.cloudflare.com/agents/model-context-protocol/guides/build-codemode-mcp-server/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI","MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/guides/","name":"Guides"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/guides/build-codemode-mcp-server/","name":"Build a single-tool Code Mode MCP server"}}]}
```

---

---
title: Build a search and execute MCP server
description: Create Code Mode search and execute MCP tools from an OpenAPI document while keeping credentials in the host Worker.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Build a search and execute MCP server

Use `openApiMcpServer()` to publish a large OpenAPI service through two Model Context Protocol (MCP) tools:

* `search` runs model-written code against the OpenAPI document.
* `execute` adds a host-provided `codemode.request()` function.

The OpenAPI document stays outside the model context unless search code returns part of it. Authentication remains in the host Worker.

Warning

Code Mode is experimental and may have breaking changes. Use caution in production.

## Prerequisites

You need a Cloudflare Workers project, an OpenAPI 3.x document, and a host-side method for authenticating API requests.

## Publish the service

1. Install Code Mode and the MCP dependencies:  
 npm  yarn  pnpm  bun  
```  
npm i @cloudflare/codemode agents @modelcontextprotocol/sdk zod  
```  
```  
yarn add @cloudflare/codemode agents @modelcontextprotocol/sdk zod  
```  
```  
pnpm add @cloudflare/codemode agents @modelcontextprotocol/sdk zod  
```  
```  
bun add @cloudflare/codemode agents @modelcontextprotocol/sdk zod  
```
2. Add a Worker Loader binding and the `nodejs_compat` compatibility flag:

  * [  wrangler.jsonc ](#tab-panel-6025)
  * [  wrangler.toml ](#tab-panel-6026)  
JSONC  
```  
{  "$schema": "./node_modules/wrangler/config-schema.json",  "name": "openapi-codemode-mcp",  "main": "src/server.ts",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": [    "nodejs_compat"  ],  "worker_loaders": [    {      "binding": "LOADER"    }  ]}  
```  
TOML  
```  
name = "openapi-codemode-mcp"main = "src/server.ts"# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = ["nodejs_compat"]  
[[worker_loaders]]binding = "LOADER"  
```
3. Load the OpenAPI document on the host. Create the MCP server with an authenticated `request` function:

  * [  JavaScript ](#tab-panel-6027)
  * [  TypeScript ](#tab-panel-6028)  
src/server.js  
```  
import { DynamicWorkerExecutor } from "@cloudflare/codemode";import { openApiMcpServer } from "@cloudflare/codemode/mcp";import { createMcpHandler } from "agents/mcp";  
const SPEC_URL = "https://api.example.com/openapi.json";const API_ORIGIN = "https://api.example.com";  
let specCache;  
async function loadSpec() {  if (specCache) return specCache;  
  const response = await fetch(SPEC_URL);  if (!response.ok) {    throw new Error(`OpenAPI request failed: ${response.status}`);  }  
  specCache = await response.json();  return specCache;}  
export default {  async fetch(request, env, ctx) {    const authorization = request.headers.get("Authorization");    if (!authorization?.startsWith("Bearer ")) {      return new Response("Bearer token required", { status: 401 });    }  
    const server = openApiMcpServer({      spec: await loadSpec(),      executor: new DynamicWorkerExecutor({ loader: env.LOADER }),      name: "example-api",      version: "1.0.0",      request: async (options) => {        if (!options.path.startsWith("/")) {          throw new Error("API path must start with a slash");        }  
        const url = new URL(`${API_ORIGIN}${options.path}`);        for (const [key, value] of Object.entries(options.query ?? {})) {          if (value !== undefined) {            url.searchParams.set(key, String(value));          }        }  
        const headers = { Authorization: authorization };        if (options.contentType) {          headers["Content-Type"] = options.contentType;        } else if (options.body !== undefined) {          headers["Content-Type"] = "application/json";        }  
        const response = await fetch(url, {          method: options.method,          headers,          body:            options.body === undefined              ? undefined              : options.rawBody                ? options.body                : JSON.stringify(options.body),        });  
        if (response.status === 204) return null;  
        const responseType = response.headers.get("Content-Type") ?? "";        const result = responseType.includes("application/json")          ? await response.json()          : await response.text();  
        if (!response.ok) {          throw new Error(`API request failed: ${response.status}`);        }        return result;      },    });  
    return createMcpHandler(server, { route: "/mcp" })(request, env, ctx);  },};  
```  
src/server.ts  
```  
import { DynamicWorkerExecutor } from "@cloudflare/codemode";import { openApiMcpServer } from "@cloudflare/codemode/mcp";import { createMcpHandler } from "agents/mcp";  
const SPEC_URL = "https://api.example.com/openapi.json";const API_ORIGIN = "https://api.example.com";  
let specCache: Record<string, unknown> | undefined;  
async function loadSpec(): Promise<Record<string, unknown>> {  if (specCache) return specCache;  
  const response = await fetch(SPEC_URL);  if (!response.ok) {    throw new Error(`OpenAPI request failed: ${response.status}`);  }  
  specCache = (await response.json()) as Record<string, unknown>;  return specCache;}  
export default {  async fetch(request, env, ctx): Promise<Response> {    const authorization = request.headers.get("Authorization");    if (!authorization?.startsWith("Bearer ")) {      return new Response("Bearer token required", { status: 401 });    }  
    const server = openApiMcpServer({      spec: await loadSpec(),      executor: new DynamicWorkerExecutor({ loader: env.LOADER }),      name: "example-api",      version: "1.0.0",      request: async (options) => {        if (!options.path.startsWith("/")) {          throw new Error("API path must start with a slash");        }  
        const url = new URL(`${API_ORIGIN}${options.path}`);        for (const [key, value] of Object.entries(options.query ?? {})) {          if (value !== undefined) {            url.searchParams.set(key, String(value));          }        }  
        const headers: Record<string, string> = { Authorization: authorization };        if (options.contentType) {          headers["Content-Type"] = options.contentType;        } else if (options.body !== undefined) {          headers["Content-Type"] = "application/json";        }  
        const response = await fetch(url, {          method: options.method,          headers,          body:            options.body === undefined              ? undefined              : options.rawBody                ? (options.body as string)                : JSON.stringify(options.body),        });  
        if (response.status === 204) return null;  
        const responseType = response.headers.get("Content-Type") ?? "";        const result = responseType.includes("application/json")          ? await response.json()          : await response.text();  
        if (!response.ok) {          throw new Error(`API request failed: ${response.status}`);        }        return result;      },    });  
    return createMcpHandler(server, { route: "/mcp" })(      request,      env,      ctx,    );  },} satisfies ExportedHandler<Env>;  
```
4. Deploy the Worker:  
 npm  yarn  pnpm  
```  
npx wrangler deploy  
```  
```  
yarn wrangler deploy  
```  
```  
pnpm wrangler deploy  
```
5. In an MCP client, connect to `https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/mcp`. Include the bearer token required by your Worker.
6. List the MCP tools. Verify that the server exposes `search` and `execute`.

## Search the OpenAPI document

Call `search` before `execute`. Search code can inspect the document without making API requests:

JavaScript

```
async () => {  const spec = await codemode.spec();  return Object.entries(spec.paths)    .filter(([path]) => path.includes("/orders"))    .map(([path, operations]) => ({      path,      methods: Object.keys(operations),    }));};
```

Local OpenAPI `$ref` values resolve inside the sandbox when code calls `codemode.spec()`. External references remain unresolved.

## Call the API

The `execute` tool includes the same `codemode.spec()` method and the host-provided `codemode.request()` method:

JavaScript

```
async () => {  const response = await codemode.request({    method: "GET",    path: "/orders",    query: { status: "processing", limit: 20 },  });
  return response.items.map(({ id, status }) => ({ id, status }));};
```

The host callback receives `method`, `path`, optional `query`, optional `body`, optional `contentType`, and optional `rawBody` fields. For exact types, refer to the [openApiMcpServer() API](https://developers.cloudflare.com/agents/tools/codemode/api-reference/#openapimcpserver).

The `search` and `execute` tools use fixed example snippets. An optional `description` is appended to the `execute` tool description. This function does not use the `{{types}}` or `{{example}}` placeholders supported by [codeMcpServer()](https://developers.cloudflare.com/agents/model-context-protocol/guides/build-codemode-mcp-server/).

## Protect the API

The example reads the bearer token before creating the MCP server. Its request callback adds that token to outbound requests. The token never enters the sandbox.

`openApiMcpServer()` does not provide durable approval for each request inside `execute`. Enforce authorization and any required per-operation approval in the host callback before applying side effects. Validate paths instead of accepting arbitrary origins.

Do not include secrets in the OpenAPI document or API results. Both are available to model-written code.

`DynamicWorkerExecutor` blocks direct external `fetch()` and `connect()` calls by default. Generated code reaches the service only through the host request callback.

## Limit results

Have model-written code select, map, aggregate, or paginate data before returning. The publisher limits final MCP responses to approximately 6,000 estimated tokens and marks truncated responses with `--- TRUNCATED ---`.

Truncation does not reduce API work already performed. Return focused identifiers, status fields, counts, and errors that support the model's next decision.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server/#page","headline":"Build a search and execute MCP server · Cloudflare Agents docs","description":"Create Code Mode search and execute MCP tools from an OpenAPI document while keeping credentials in the host Worker.","url":"https://developers.cloudflare.com/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI","MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/guides/","name":"Guides"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server/","name":"Build a search and execute MCP server"}}]}
```

---

---
title: Connect to an MCP server
description: Create a Cloudflare Agent that connects to an external MCP server and uses its tools.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Connect to an MCP server

Your Agent can connect to external [Model Context Protocol (MCP) ↗](https://modelcontextprotocol.io) servers to access their tools and extend your Agent's capabilities. In this tutorial, you'll create an Agent that connects to an MCP server and uses one of its tools.

## What you will build

An Agent with endpoints to:

* Connect to an MCP server
* List available tools from connected servers
* Get the connection status

## Prerequisites

An MCP server to connect to (or use the public example in this tutorial).

## 1\. Create a basic Agent

1. Create a new Agent project using the `hello-world` template:  
 npm  yarn  pnpm  
```  
npm create cloudflare@latest -- my-mcp-client --template=cloudflare/ai/demos/hello-world  
```  
```  
yarn create cloudflare my-mcp-client --template=cloudflare/ai/demos/hello-world  
```  
```  
pnpm create cloudflare@latest my-mcp-client --template=cloudflare/ai/demos/hello-world  
```
2. Move into the project directory:  
Terminal window  
```  
cd my-mcp-client  
```  
Your Agent is ready! The template includes a minimal Agent in `src/index.ts`:

  * [  JavaScript ](#tab-panel-6029)
  * [  TypeScript ](#tab-panel-6030)  
JavaScript  
```  
import { Agent, routeAgentRequest } from "agents";  
export class HelloAgent extends Agent {  async onRequest(request) {    return new Response("Hello, Agent!", { status: 200 });  }}  
export default {  async fetch(request, env) {    return (      (await routeAgentRequest(request, env, { cors: true })) ||      new Response("Not found", { status: 404 })    );  },};  
```  
TypeScript  
```  
import { Agent, routeAgentRequest } from "agents";  
type Env = {  HelloAgent: DurableObjectNamespace<HelloAgent>;};  
export class HelloAgent extends Agent<Env> {  async onRequest(request: Request): Promise<Response> {    return new Response("Hello, Agent!", { status: 200 });  }}  
export default {  async fetch(request: Request, env: Env) {    return (      (await routeAgentRequest(request, env, { cors: true })) ||      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;  
```

## 2\. Add MCP connection endpoint

1. Add an endpoint to connect to MCP servers. Update your Agent class in `src/index.ts`:

  * [  JavaScript ](#tab-panel-6033)
  * [  TypeScript ](#tab-panel-6034)  
JavaScript  
```  
export class HelloAgent extends Agent {  async onRequest(request) {    const url = new URL(request.url);  
    // Connect to an MCP server    if (url.pathname.endsWith("add-mcp") && request.method === "POST") {      const { serverUrl, name } = await request.json();  
      const { id, authUrl } = await this.addMcpServer(name, serverUrl);  
      if (authUrl) {        // OAuth required - return auth URL        return new Response(JSON.stringify({ serverId: id, authUrl }), {          headers: { "Content-Type": "application/json" },        });      }  
      return new Response(        JSON.stringify({ serverId: id, status: "connected" }),        { headers: { "Content-Type": "application/json" } },      );    }  
    return new Response("Not found", { status: 404 });  }}  
```  
TypeScript  
```  
export class HelloAgent extends Agent<Env> {  async onRequest(request: Request): Promise<Response> {    const url = new URL(request.url);  
    // Connect to an MCP server    if (url.pathname.endsWith("add-mcp") && request.method === "POST") {      const { serverUrl, name } = (await request.json()) as {        serverUrl: string;        name: string;      };  
      const { id, authUrl } = await this.addMcpServer(name, serverUrl);  
      if (authUrl) {        // OAuth required - return auth URL        return new Response(          JSON.stringify({ serverId: id, authUrl }),          { headers: { "Content-Type": "application/json" } },        );      }  
      return new Response(        JSON.stringify({ serverId: id, status: "connected" }),        { headers: { "Content-Type": "application/json" } },      );    }  
    return new Response("Not found", { status: 404 });  }}  
```

The `addMcpServer()` method connects to an MCP server. If the server requires OAuth authentication, it returns an `authUrl` that users must visit to complete authorization.

## 3\. Test the connection

1. Start your development server:  
Terminal window  
```  
npm start  
```
2. In a new terminal, connect to an MCP server (using a public example):  
Terminal window  
```  
curl -X POST http://localhost:8788/agents/hello-agent/default/add-mcp \  -H "Content-Type: application/json" \  -d '{    "serverUrl": "https://docs.mcp.cloudflare.com/mcp",    "name": "Example Server"  }'  
```  
You should see a response with the server ID:  
```  
{  "serverId": "example-server-id",  "status": "connected"}  
```

## 4\. List available tools

1. Add an endpoint to see which tools are available from connected servers:

  * [  JavaScript ](#tab-panel-6031)
  * [  TypeScript ](#tab-panel-6032)  
JavaScript  
```  
export class HelloAgent extends Agent {  async onRequest(request) {    const url = new URL(request.url);  
    // ... previous add-mcp endpoint ...  
    // List MCP state (servers, tools, etc)    if (url.pathname.endsWith("mcp-state") && request.method === "GET") {      const mcpState = this.getMcpServers();  
      return Response.json(mcpState);    }  
    return new Response("Not found", { status: 404 });  }}  
```  
TypeScript  
```  
export class HelloAgent extends Agent<Env> {  async onRequest(request: Request): Promise<Response> {    const url = new URL(request.url);  
    // ... previous add-mcp endpoint ...  
    // List MCP state (servers, tools, etc)    if (url.pathname.endsWith("mcp-state") && request.method === "GET") {      const mcpState = this.getMcpServers();  
      return Response.json(mcpState);    }  
    return new Response("Not found", { status: 404 });  }}  
```
2. Test it:  
Terminal window  
```  
curl http://localhost:8788/agents/hello-agent/default/mcp-state  
```  
You'll see all connected servers, their connection states, and available tools:  
```  
{  "servers": {    "example-server-id": {      "name": "Example Server",      "state": "ready",      "server_url": "https://docs.mcp.cloudflare.com/mcp",      ...    }  },  "tools": [    {      "name": "add",      "description": "Add two numbers",      "serverId": "example-server-id",      ...    }  ]}  
```

## Summary

You created an Agent that can:

* Connect to external MCP servers dynamically
* Handle OAuth authentication flows when required
* List all available tools from connected servers
* Monitor connection status

Connections persist in the Agent's [SQL storage](https://developers.cloudflare.com/agents/runtime/lifecycle/state/), so they remain active across requests.

## Next steps

[ Handle OAuth flows ](https://developers.cloudflare.com/agents/model-context-protocol/guides/oauth-mcp-client/) Configure OAuth callbacks and error handling. 

[ MCP Client API ](https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/) Complete API documentation for MCP clients.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/guides/connect-mcp-client/#page","headline":"Connect to an MCP server · Cloudflare Agents docs","description":"Create a Cloudflare Agent that connects to an external MCP server and uses its tools.","url":"https://developers.cloudflare.com/agents/model-context-protocol/guides/connect-mcp-client/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/guides/","name":"Guides"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/guides/connect-mcp-client/","name":"Connect to an MCP server"}}]}
```

---

---
title: Handle OAuth with MCP servers
description: Implement OAuth authentication flows in Cloudflare Agents to connect to protected MCP servers.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Handle OAuth with MCP servers

When connecting to OAuth-protected MCP servers (like Slack or Notion), your users need to authenticate before your Agent can access their data. This guide covers implementing OAuth flows for seamless authorization.

## How it works

1. Call `addMcpServer()` with the server URL
2. If OAuth is required, an `authUrl` is returned instead of connecting immediately
3. Present the `authUrl` to your user (redirect, popup, or link)
4. User authenticates on the provider's site
5. Provider redirects back to your Agent's callback URL
6. Your Agent completes the connection automatically

The MCP client uses a built-in `DurableObjectOAuthClientProvider` to manage OAuth state securely — storing a nonce and server ID, validating on callback, and cleaning up after use or expiration.

## Initiate OAuth

When connecting to an OAuth-protected server, check if `authUrl` is returned. If present, redirect your user to complete authorization:

* [  JavaScript ](#tab-panel-6039)
* [  TypeScript ](#tab-panel-6040)

JavaScript

```
export class MyAgent extends Agent {  async onRequest(request) {    const url = new URL(request.url);
    if (url.pathname.endsWith("/connect") && request.method === "POST") {      const { id, authUrl } = await this.addMcpServer(        "Cloudflare Observability",        "https://observability.mcp.cloudflare.com/mcp",      );
      if (authUrl) {        // OAuth required - redirect user to authorize        return Response.redirect(authUrl, 302);      }
      // Already authenticated - connection complete      return Response.json({ serverId: id, status: "connected" });    }
    return new Response("Not found", { status: 404 });  }}
```

TypeScript

```
export class MyAgent extends Agent<Env> {  async onRequest(request: Request): Promise<Response> {    const url = new URL(request.url);
    if (url.pathname.endsWith("/connect") && request.method === "POST") {      const { id, authUrl } = await this.addMcpServer(        "Cloudflare Observability",        "https://observability.mcp.cloudflare.com/mcp",      );
      if (authUrl) {        // OAuth required - redirect user to authorize        return Response.redirect(authUrl, 302);      }
      // Already authenticated - connection complete      return Response.json({ serverId: id, status: "connected" });    }
    return new Response("Not found", { status: 404 });  }}
```

### Alternative approaches

Instead of an automatic redirect, you can present the `authUrl` to your user as a:

* **Popup window**: `window.open(authUrl, '_blank', 'width=600,height=700')` for dashboard-style apps
* **Clickable link**: Display as a button or link for multi-step flows
* **Deep link**: Use custom URL schemes for mobile apps

## Configure callback behavior

After OAuth completes, the provider redirects back to your Agent's callback URL. By default, successful authentication redirects to your application origin, while failed authentication displays an HTML error page with the error message.

### Redirect to your application

Redirect users back to your application after OAuth completes:

* [  JavaScript ](#tab-panel-6035)
* [  TypeScript ](#tab-panel-6036)

JavaScript

```
export class MyAgent extends Agent {  onStart() {    this.mcp.configureOAuthCallback({      successRedirect: "/dashboard",      errorRedirect: "/auth-error",    });  }}
```

TypeScript

```
export class MyAgent extends Agent<Env> {  onStart() {    this.mcp.configureOAuthCallback({      successRedirect: "/dashboard",      errorRedirect: "/auth-error",    });  }}
```

Users return to `/dashboard` on success or `/auth-error?error=<message>` on failure.

### Close popup window

If you opened OAuth in a popup, close it automatically when complete:

* [  JavaScript ](#tab-panel-6037)
* [  TypeScript ](#tab-panel-6038)

JavaScript

```
import { Agent } from "agents";
export class MyAgent extends Agent {  onStart() {    this.mcp.configureOAuthCallback({      customHandler: () => {        // Close the popup after OAuth completes        return new Response("<script>window.close();</script>", {          headers: { "content-type": "text/html" },        });      },    });  }}
```

TypeScript

```
import { Agent } from "agents";
export class MyAgent extends Agent<Env> {  onStart() {    this.mcp.configureOAuthCallback({      customHandler: () => {        // Close the popup after OAuth completes        return new Response("<script>window.close();</script>", {          headers: { "content-type": "text/html" },        });      },    });  }}
```

Your main application can detect the popup closing and refresh the connection status. If OAuth fails, the connection state becomes `"failed"` and the error message is stored in `server.error` for display in your UI.

## Monitor connection status

### React applications

Use the `useAgent` hook for real-time updates via WebSocket:

* [  JavaScript ](#tab-panel-6043)
* [  TypeScript ](#tab-panel-6044)

JavaScript

```
import { useAgent } from "agents/react";import { useState } from "react";
function App() {  const [mcpState, setMcpState] = useState({    prompts: [],    resources: [],    servers: {},    tools: [],  });
  const agent = useAgent({    agent: "my-agent",    name: "session-id",    onMcpUpdate: (mcpServers) => {      // Automatically called when MCP state changes!      setMcpState(mcpServers);    },  });
  return (    <div>      {Object.entries(mcpState.servers).map(([id, server]) => (        <div key={id}>          <strong>{server.name}</strong>: {server.state}          {server.state === "authenticating" && server.auth_url && (            <button onClick={() => window.open(server.auth_url, "_blank")}>              Authorize            </button>          )}          {server.state === "failed" && server.error && (            <p className="error">{server.error}</p>          )}        </div>      ))}    </div>  );}
```

TypeScript

```
import { useAgent } from "agents/react";import { useState } from "react";import type { MCPServersState } from "agents";
function App() {  const [mcpState, setMcpState] = useState<MCPServersState>({    prompts: [],    resources: [],    servers: {},    tools: [],  });
  const agent = useAgent({    agent: "my-agent",    name: "session-id",    onMcpUpdate: (mcpServers: MCPServersState) => {      // Automatically called when MCP state changes!      setMcpState(mcpServers);    },  });
  return (    <div>      {Object.entries(mcpState.servers).map(([id, server]) => (        <div key={id}>          <strong>{server.name}</strong>: {server.state}          {server.state === "authenticating" && server.auth_url && (            <button onClick={() => window.open(server.auth_url, "_blank")}>              Authorize            </button>          )}          {server.state === "failed" && server.error && (            <p className="error">{server.error}</p>          )}        </div>      ))}    </div>  );}
```

The `onMcpUpdate` callback fires automatically when MCP state changes — no polling needed.

### Other frameworks

Poll the connection status via an endpoint:

* [  JavaScript ](#tab-panel-6041)
* [  TypeScript ](#tab-panel-6042)

JavaScript

```
export class MyAgent extends Agent {  async onRequest(request) {    const url = new URL(request.url);
    if (      url.pathname.endsWith("connection-status") &&      request.method === "GET"    ) {      const mcpState = this.getMcpServers();
      const connections = Object.entries(mcpState.servers).map(        ([id, server]) => ({          serverId: id,          name: server.name,          state: server.state,          isReady: server.state === "ready",          needsAuth: server.state === "authenticating",          authUrl: server.auth_url,        }),      );
      return Response.json(connections);    }
    return new Response("Not found", { status: 404 });  }}
```

TypeScript

```
export class MyAgent extends Agent<Env> {  async onRequest(request: Request): Promise<Response> {    const url = new URL(request.url);
    if (      url.pathname.endsWith("connection-status") &&      request.method === "GET"    ) {      const mcpState = this.getMcpServers();
      const connections = Object.entries(mcpState.servers).map(        ([id, server]) => ({          serverId: id,          name: server.name,          state: server.state,          isReady: server.state === "ready",          needsAuth: server.state === "authenticating",          authUrl: server.auth_url,        }),      );
      return Response.json(connections);    }
    return new Response("Not found", { status: 404 });  }}
```

Connection states flow: `authenticating` (needs OAuth) → `connecting` (completing setup) → `ready` (available for use)

## Handle failures

When OAuth fails, the connection state becomes `"failed"` and the error message is stored in the `server.error` field. Display this error in your UI and allow users to retry:

* [  JavaScript ](#tab-panel-6045)
* [  TypeScript ](#tab-panel-6046)

JavaScript

```
import { useAgent } from "agents/react";import { useState } from "react";
function App() {  const [mcpState, setMcpState] = useState({    prompts: [],    resources: [],    servers: {},    tools: [],  });
  const agent = useAgent({    agent: "my-agent",    name: "session-id",    onMcpUpdate: setMcpState,  });
  const handleRetry = async (serverId, serverUrl, name) => {    // Remove failed connection    await fetch(`/agents/my-agent/session-id/disconnect`, {      method: "POST",      body: JSON.stringify({ serverId }),    });
    // Retry connection    const response = await fetch(`/agents/my-agent/session-id/connect`, {      method: "POST",      body: JSON.stringify({ serverUrl, name }),    });    const { authUrl } = await response.json();    if (authUrl) window.open(authUrl, "_blank");  };
  return (    <div>      {Object.entries(mcpState.servers).map(([id, server]) => (        <div key={id}>          <strong>{server.name}</strong>: {server.state}          {server.state === "failed" && (            <div>              {server.error && <p className="error">{server.error}</p>}              <button                onClick={() => handleRetry(id, server.server_url, server.name)}              >                Retry Connection              </button>            </div>          )}        </div>      ))}    </div>  );}
```

TypeScript

```
import { useAgent } from "agents/react";import { useState } from "react";import type { MCPServersState } from "agents";
function App() {  const [mcpState, setMcpState] = useState<MCPServersState>({    prompts: [],    resources: [],    servers: {},    tools: [],  });
  const agent = useAgent({    agent: "my-agent",    name: "session-id",    onMcpUpdate: setMcpState,  });
  const handleRetry = async (    serverId: string,    serverUrl: string,    name: string,  ) => {    // Remove failed connection    await fetch(`/agents/my-agent/session-id/disconnect`, {      method: "POST",      body: JSON.stringify({ serverId }),    });
    // Retry connection    const response = await fetch(`/agents/my-agent/session-id/connect`, {      method: "POST",      body: JSON.stringify({ serverUrl, name }),    });    const { authUrl } = await response.json();    if (authUrl) window.open(authUrl, "_blank");  };
  return (    <div>      {Object.entries(mcpState.servers).map(([id, server]) => (        <div key={id}>          <strong>{server.name}</strong>: {server.state}          {server.state === "failed" && (            <div>              {server.error && <p className="error">{server.error}</p>}              <button                onClick={() => handleRetry(id, server.server_url, server.name)}              >                Retry Connection              </button>            </div>          )}        </div>      ))}    </div>  );}
```

Common failure reasons:

* **User canceled**: Closed OAuth window before completing authorization
* **Invalid credentials**: Provider credentials were incorrect
* **Permission denied**: User lacks required permissions
* **Expired session**: OAuth session timed out

Failed connections remain in state until removed with `removeMcpServer(serverId)`. The error message is automatically escaped to prevent XSS attacks, so it is safe to display directly in your UI.

## Complete example

This example demonstrates a complete OAuth integration with Cloudflare Observability. Users connect, authorize in a popup window, and the connection becomes available. Errors are automatically stored in the connection state for display in your UI.

* [  JavaScript ](#tab-panel-6047)
* [  TypeScript ](#tab-panel-6048)

JavaScript

```
import { Agent, routeAgentRequest } from "agents";
export class MyAgent extends Agent {  onStart() {    this.mcp.configureOAuthCallback({      customHandler: () => {        // Close popup after OAuth completes (success or failure)        return new Response("<script>window.close();</script>", {          headers: { "content-type": "text/html" },        });      },    });  }
  async onRequest(request) {    const url = new URL(request.url);
    // Connect to MCP server    if (url.pathname.endsWith("/connect") && request.method === "POST") {      const { id, authUrl } = await this.addMcpServer(        "Cloudflare Observability",        "https://observability.mcp.cloudflare.com/mcp",      );
      if (authUrl) {        return Response.json({          serverId: id,          authUrl: authUrl,          message: "Please authorize access",        });      }
      return Response.json({ serverId: id, status: "connected" });    }
    // Check connection status    if (url.pathname.endsWith("/status") && request.method === "GET") {      const mcpState = this.getMcpServers();      const connections = Object.entries(mcpState.servers).map(        ([id, server]) => ({          serverId: id,          name: server.name,          state: server.state,          authUrl: server.auth_url,        }),      );      return Response.json(connections);    }
    // Disconnect    if (url.pathname.endsWith("/disconnect") && request.method === "POST") {      const { serverId } = await request.json();      await this.removeMcpServer(serverId);      return Response.json({ message: "Disconnected" });    }
    return new Response("Not found", { status: 404 });  }}
export default {  async fetch(request, env) {    return (      (await routeAgentRequest(request, env, { cors: true })) ||      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
import { Agent, routeAgentRequest } from "agents";
type Env = {  MyAgent: DurableObjectNamespace<MyAgent>;};
export class MyAgent extends Agent<Env> {  onStart() {    this.mcp.configureOAuthCallback({      customHandler: () => {        // Close popup after OAuth completes (success or failure)        return new Response("<script>window.close();</script>", {          headers: { "content-type": "text/html" },        });      },    });  }
  async onRequest(request: Request): Promise<Response> {    const url = new URL(request.url);
    // Connect to MCP server    if (url.pathname.endsWith("/connect") && request.method === "POST") {      const { id, authUrl } = await this.addMcpServer(        "Cloudflare Observability",        "https://observability.mcp.cloudflare.com/mcp",      );
      if (authUrl) {        return Response.json({          serverId: id,          authUrl: authUrl,          message: "Please authorize access",        });      }
      return Response.json({ serverId: id, status: "connected" });    }
    // Check connection status    if (url.pathname.endsWith("/status") && request.method === "GET") {      const mcpState = this.getMcpServers();      const connections = Object.entries(mcpState.servers).map(        ([id, server]) => ({          serverId: id,          name: server.name,          state: server.state,          authUrl: server.auth_url,        }),      );      return Response.json(connections);    }
    // Disconnect    if (url.pathname.endsWith("/disconnect") && request.method === "POST") {      const { serverId } = (await request.json()) as { serverId: string };      await this.removeMcpServer(serverId);      return Response.json({ message: "Disconnected" });    }
    return new Response("Not found", { status: 404 });  }}
export default {  async fetch(request: Request, env: Env) {    return (      (await routeAgentRequest(request, env, { cors: true })) ||      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

## Related

[ Connect to an MCP server ](https://developers.cloudflare.com/agents/model-context-protocol/guides/connect-mcp-client/) Get started without OAuth. 

[ MCP Client API ](https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/) Complete API documentation for MCP clients.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/guides/oauth-mcp-client/#page","headline":"Handle OAuth with MCP servers · Cloudflare Agents docs","description":"Implement OAuth authentication flows in Cloudflare Agents to connect to protected MCP servers.","url":"https://developers.cloudflare.com/agents/model-context-protocol/guides/oauth-mcp-client/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/guides/","name":"Guides"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/guides/oauth-mcp-client/","name":"Handle OAuth with MCP servers"}}]}
```

---

---
title: Build a Remote MCP server
description: Deploy a remote MCP server on Cloudflare with optional authentication using Streamable HTTP transport.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Build a Remote MCP server

This guide will show you how to deploy your own remote MCP server on Cloudflare using [Streamable HTTP transport](https://developers.cloudflare.com/agents/model-context-protocol/protocol/transport/), the current MCP specification standard. You have two options:

* **Without authentication** — anyone can connect and use the server (no login required).
* **With [authentication and authorization](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/#add-authentication)** — users sign in before accessing tools, and you can control which tools an agent can call based on the user's permissions.

## Choosing an approach

The Agents SDK provides multiple ways to create MCP servers. Choose the approach that fits your use case:

| Approach                                                                                                | Stateful? | Requires Durable Objects? | Best for                                       |
| ------------------------------------------------------------------------------------------------------- | --------- | ------------------------- | ---------------------------------------------- |
| [createMcpHandler()](https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/) | No        | No                        | Stateless tools, simplest setup                |
| [McpAgent](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/)             | Yes       | Yes                       | Stateful tools, per-session state, elicitation |
| Raw WebStandardStreamableHTTPServerTransport                                                            | No        | No                        | Full control, no SDK dependency                |

* **`createMcpHandler()`** is the fastest way to get a stateless MCP server running. Use it when your tools do not need per-session state.
* **`McpAgent`** gives you a Durable Object per session with built-in state management, elicitation support, and both SSE and Streamable HTTP transports.
* **Raw transport** gives you full control if you want to use the `@modelcontextprotocol/sdk` directly without the Agents SDK helpers.

## Deploy your first MCP server

You can start by deploying a [public MCP server ↗](https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-authless) without authentication, then add user authentication and scoped authorization later. If you already know your server will require authentication, you can skip ahead to the [next section](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/#add-authentication).

### Via the dashboard

The button below will guide you through everything you need to do to deploy an [example MCP server ↗](https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-authless) to your Cloudflare account:

[![Deploy to Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-authless)

Once deployed, this server will be live at your `workers.dev` subdomain (for example, `remote-mcp-server-authless.your-account.workers.dev/mcp`). You can connect to it immediately using the [AI Playground ↗](https://playground.ai.cloudflare.com/) (a remote MCP client), [MCP inspector ↗](https://github.com/modelcontextprotocol/inspector) or [other MCP clients](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/#connect-from-an-mcp-client-via-a-local-proxy).

A new git repository will be set up on your GitHub or GitLab account for your MCP server, configured to automatically deploy to Cloudflare each time you push a change or merge a pull request to the main branch of the repository. You can clone this repository, [develop locally](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/#via-the-cli), and start customizing the MCP server with your own [tools](https://developers.cloudflare.com/agents/model-context-protocol/protocol/tools/).

### Via the CLI

You can use the [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler) to create a new MCP Server on your local machine and deploy it to Cloudflare.

1. Open a terminal and run the following command:  
 npm  yarn  pnpm  
```  
npm create cloudflare@latest -- remote-mcp-server-authless --template=cloudflare/ai/demos/remote-mcp-authless  
```  
```  
yarn create cloudflare remote-mcp-server-authless --template=cloudflare/ai/demos/remote-mcp-authless  
```  
```  
pnpm create cloudflare@latest remote-mcp-server-authless --template=cloudflare/ai/demos/remote-mcp-authless  
```  
During setup, select the following options: - For _Do you want to add an AGENTS.md file to help AI coding tools understand Cloudflare APIs?_, choose `No`. - For _Do you want to use git for version control?_, choose `No`. - For _Do you want to deploy your application?_, choose `No` (we will be testing the server before deploying).  
Now, you have the MCP server setup, with dependencies installed.
2. Move into the project folder:  
Terminal window  
```  
cd remote-mcp-server-authless  
```
3. In the directory of your new project, run the following command to start the development server:  
Terminal window  
```  
npm start  
```  
```  
⎔ Starting local server...[wrangler:info] Ready on http://localhost:8788  
```  
Check the command output for the local port. In this example, the MCP server runs on port `8788`, and the MCP endpoint URL is `http://localhost:8788/mcp`.  
Note  
You cannot interact with the MCP server by opening the `/mcp` URL directly in a web browser. The `/mcp` endpoint expects an MCP client to send MCP protocol messages, which a browser does not do by default. In the next step, we will demonstrate how to connect to the server using an MCP client.
4. To test the server locally:

  1. In a new terminal, run the [MCP inspector ↗](https://github.com/modelcontextprotocol/inspector). The MCP inspector is an interactive MCP client that allows you to connect to your MCP server and invoke tools from a web browser.  
  Terminal window  
  ```  
  npx @modelcontextprotocol/inspector@latest  
  ```  
  ```  
  🚀 MCP Inspector is up and running at:  http://localhost:5173/?MCP_PROXY_AUTH_TOKEN=46ab..cd3  
  🌐 Opening browser...  
  ```  
  The MCP Inspector will launch in your web browser. You can also launch it manually by opening a browser and going to `http://localhost:<PORT>`. Check the command output for the local port where MCP Inspector is running. In this example, MCP Inspector is served on port `5173`.
  2. In the MCP inspector, enter the URL of your MCP server (`http://localhost:8788/mcp`), and select **Connect**. Select **List Tools** to show the tools that your MCP server exposes.
5. You can now deploy your MCP server to Cloudflare. From your project directory, run:  
Terminal window  
```  
npx wrangler@latest deploy  
```  
If you have already [connected a git repository](https://developers.cloudflare.com/workers/ci-cd/builds/) to the Worker with your MCP server, you can deploy your MCP server by pushing a change or merging a pull request to the main branch of the repository.  
The MCP server will be deployed to your `*.workers.dev` subdomain at `https://remote-mcp-server-authless.your-account.workers.dev/mcp`.
6. To test the remote MCP server, take the URL of your deployed MCP server (`https://remote-mcp-server-authless.your-account.workers.dev/mcp`) and enter it in the MCP inspector running on `http://localhost:5173`.

You now have a remote MCP server that MCP clients can connect to.

## Connect from an MCP client via a local proxy

Now that your remote MCP server is running, you can use the [mcp-remote local proxy ↗](https://www.npmjs.com/package/mcp-remote) to connect Claude Desktop or other MCP clients to it — even if your MCP client does not support remote transport or authorization on the client side. This lets you test what an interaction with your remote MCP server will be like with a real MCP client.

For example, to connect from Claude Desktop:

1. Update your Claude Desktop configuration to point to the URL of your MCP server:  
```  
{  "mcpServers": {    "math": {      "command": "npx",      "args": [        "mcp-remote",        "https://remote-mcp-server-authless.your-account.workers.dev/mcp"      ]    }  }}  
```
2. Restart Claude Desktop to load the MCP Server. Once this is done, Claude will be able to make calls to your remote MCP server.
3. To test, ask Claude to use one of your tools. For example:  
```  
Could you use the math tool to add 23 and 19?  
```  
Claude should invoke the tool and show the result generated by the remote MCP server.

To learn how to use remote MCP servers with other MCP clients, refer to [Test a Remote MCP Server](https://developers.cloudflare.com/agents/model-context-protocol/guides/test-remote-mcp-server/).

## Add Authentication

The public MCP server example you deployed earlier allows any client to connect and invoke tools without logging in. To add user authentication to your MCP server, you can integrate Cloudflare Access or a third-party service as the OAuth provider. Your MCP server handles secure login flows and issues access tokens that MCP clients can use to make authenticated tool calls. Users sign in with the OAuth provider and grant their AI agent permission to interact with the tools exposed by your MCP server, using scoped permissions.

### Cloudflare Access OAuth

You can configure your MCP server to require user authentication through Cloudflare Access. Cloudflare Access acts as an identity aggregator and verifies user emails, signals from your existing [identity providers](https://developers.cloudflare.com/cloudflare-one/integrations/identity-providers/) (such as GitHub or Google), and other attributes such as IP address or device certificates. When users connect to the MCP server, they will be prompted to log in to the configured identity provider and are only granted access if they pass your [Access policies](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/#selectors).

For a step-by-step deployment guide, refer to [Secure MCP servers with Access for SaaS](https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/secure-mcp-servers/).

### Third-party OAuth

You can connect your MCP server with any [OAuth provider](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/#2-third-party-oauth-provider) that supports the OAuth 2.0 specification, including GitHub, Google, Slack, [Stytch](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/#stytch), [Auth0](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/#auth0), [WorkOS](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/#workos), and more.

The following example demonstrates how to use GitHub as an OAuth provider.

#### Step 1 — Create a new MCP server

Run the following command to create a new MCP server with GitHub OAuth:

 npm  yarn  pnpm 

```
npm create cloudflare@latest -- my-mcp-server-github-auth --template=cloudflare/ai/demos/remote-mcp-github-oauth
```

```
yarn create cloudflare my-mcp-server-github-auth --template=cloudflare/ai/demos/remote-mcp-github-oauth
```

```
pnpm create cloudflare@latest my-mcp-server-github-auth --template=cloudflare/ai/demos/remote-mcp-github-oauth
```

Now, you have the MCP server setup, with dependencies installed. Move into that project folder:

Terminal window

```
cd my-mcp-server-github-auth
```

You'll notice that in the example MCP server, if you open `src/index.ts`, the primary difference is that the `defaultHandler` is set to the `GitHubHandler`:

TypeScript

```
import GitHubHandler from "./github-handler";
export default new OAuthProvider({  apiRoute: "/mcp",  apiHandler: MyMCP.serve("/mcp"),  defaultHandler: GitHubHandler,  authorizeEndpoint: "/authorize",  tokenEndpoint: "/token",  clientRegistrationEndpoint: "/register",});
```

This ensures that your users are redirected to GitHub to authenticate. To get this working though, you need to create OAuth client apps in the steps below.

#### Step 2 — Create an OAuth App

You'll need to create two [GitHub OAuth Apps ↗](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) to use GitHub as an authentication provider for your MCP server — one for local development, and one for production.

#### Step 2.1 — Create a new OAuth App for local development

1. Navigate to [github.com/settings/developers ↗](https://github.com/settings/developers) to create a new OAuth App with the following settings:

  * **Application name**: `My MCP Server (local)`
  * **Homepage URL**: `http://localhost:8788`
  * **Authorization callback URL**: `http://localhost:8788/callback`
2. For the OAuth app you just created, add the client ID of the OAuth app as `GITHUB_CLIENT_ID` and generate a client secret, adding it as `GITHUB_CLIENT_SECRET` to a `.env` file in the root of your project, which [will be used to set secrets in local development](https://developers.cloudflare.com/workers/configuration/secrets/).  
Terminal window  
```  
touch .envecho 'GITHUB_CLIENT_ID="your-client-id"' >> .envecho 'GITHUB_CLIENT_SECRET="your-client-secret"' >> .envcat .env  
```
3. Run the following command to start the development server:  
Terminal window  
```  
npm start  
```  
Your MCP server is now running on `http://localhost:8788/mcp`.
4. In a new terminal, run the [MCP inspector ↗](https://github.com/modelcontextprotocol/inspector). The MCP inspector is an interactive MCP client that allows you to connect to your MCP server and invoke tools from a web browser.  
Terminal window  
```  
npx @modelcontextprotocol/inspector@latest  
```
5. Open the MCP inspector in your web browser:  
Terminal window  
```  
open http://localhost:5173  
```
6. In the inspector, enter the URL of your MCP server, `http://localhost:8788/mcp`
7. In the main panel on the right, click the **OAuth Settings** button and then click **Quick OAuth Flow**.  
You should be redirected to a GitHub login or authorization page. After authorizing the MCP Client (the inspector) access to your GitHub account, you will be redirected back to the inspector.
8. Click **Connect** in the sidebar and you should see the "List Tools" button, which will list the tools that your MCP server exposes.

#### Step 2.2 — Create a new OAuth App for production

You'll need to repeat [Step 2.1](#step-21--create-a-new-oauth-app-for-local-development) to create a new OAuth App for production.

1. Navigate to [github.com/settings/developers ↗](https://github.com/settings/developers) to create a new OAuth App with the following settings:
* **Application name**: `My MCP Server (production)`
* **Homepage URL**: Enter the workers.dev URL of your deployed MCP server (ex: `worker-name.account-name.workers.dev`)
* **Authorization callback URL**: Enter the `/callback` path of the workers.dev URL of your deployed MCP server (ex: `worker-name.account-name.workers.dev/callback`)
1. For the OAuth app you just created, add the client ID and client secret, using Wrangler CLI:

Terminal window

```
npx wrangler secret put GITHUB_CLIENT_ID
```

Terminal window

```
npx wrangler secret put GITHUB_CLIENT_SECRET
```

Terminal window

```
npx wrangler secret put COOKIE_ENCRYPTION_KEY
```

Use any random string for `COOKIE_ENCRYPTION_KEY`, for example the output of `openssl rand -hex 32`.

Warning

When you create the first secret, Wrangler will ask if you want to create a new Worker. Submit "Y" to create a new Worker and save the secret.

1. Set up a KV namespace  
a. Create the KV namespace:  
Terminal window  
```  
npx wrangler kv namespace create "OAUTH_KV"  
```  
b. Update the `wrangler.jsonc` file with the resulting KV ID:  
```  
{  "kvNamespaces": [    {      "binding": "OAUTH_KV",      "id": "<YOUR_KV_NAMESPACE_ID>"    }  ]}  
```
2. Deploy the MCP server to your Cloudflare `workers.dev` domain:  
Terminal window  
```  
npm run deploy  
```
3. Connect to your server running at `worker-name.account-name.workers.dev/mcp` using the [AI Playground ↗](https://playground.ai.cloudflare.com/), MCP Inspector, or [other MCP clients](https://developers.cloudflare.com/agents/model-context-protocol/guides/test-remote-mcp-server/), and authenticate with GitHub.

## Next steps

[ MCP Tools ](https://developers.cloudflare.com/agents/model-context-protocol/protocol/tools/) Add tools to your MCP server. 

[ Authorization ](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/) Customize authentication and authorization.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/#page","headline":"Build a Remote MCP server · Cloudflare Agents docs","description":"Deploy a remote MCP server on Cloudflare with optional authentication using Streamable HTTP transport.","url":"https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/guides/","name":"Guides"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/guides/remote-mcp-server/","name":"Build a Remote MCP server"}}]}
```

---

---
title: Securing MCP servers
description: Secure your MCP servers with OAuth 2.1, token validation, and scope-based access control on Cloudflare.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Securing MCP servers

MCP servers, like any web application, need to be secured so they can be used by trusted users without abuse. The MCP specification uses OAuth 2.1 for authentication between MCP clients and servers.

This guide covers security best practices for MCP servers that act as OAuth proxies to third-party providers (like GitHub or Google).

## OAuth protection with workers-oauth-provider

Cloudflare's [workers-oauth-provider ↗](https://github.com/cloudflare/workers-oauth-provider) handles token management, client registration, and access token validation:

* [  JavaScript ](#tab-panel-6049)
* [  TypeScript ](#tab-panel-6050)

JavaScript

```
import { OAuthProvider } from "@cloudflare/workers-oauth-provider";import { MyMCP } from "./mcp";
export default new OAuthProvider({  authorizeEndpoint: "/authorize",  tokenEndpoint: "/token",  clientRegistrationEndpoint: "/register",  apiRoute: "/mcp",  apiHandler: MyMCP.serve("/mcp"),  defaultHandler: AuthHandler,});
```

TypeScript

```
import { OAuthProvider } from "@cloudflare/workers-oauth-provider";import { MyMCP } from "./mcp";
export default new OAuthProvider({  authorizeEndpoint: "/authorize",  tokenEndpoint: "/token",  clientRegistrationEndpoint: "/register",  apiRoute: "/mcp",  apiHandler: MyMCP.serve("/mcp"),  defaultHandler: AuthHandler,});
```

## Consent dialog security

When your MCP server proxies to third-party OAuth providers, you must implement your own consent dialog before forwarding users upstream. This prevents the "confused deputy" problem where attackers could exploit cached consent.

### CSRF protection

Without CSRF protection, attackers can trick users into approving malicious OAuth clients. Use a random token stored in a secure cookie:

* [  JavaScript ](#tab-panel-6053)
* [  TypeScript ](#tab-panel-6054)

JavaScript

```
// Generate CSRF token when showing consent formfunction generateCSRFProtection() {  const token = crypto.randomUUID();  const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;  return { token, setCookie };}
// Validate CSRF token on form submissionfunction validateCSRFToken(formData, request) {  const tokenFromForm = formData.get("csrf_token");  const cookieHeader = request.headers.get("Cookie") || "";  const tokenFromCookie = cookieHeader    .split(";")    .find((c) => c.trim().startsWith("__Host-CSRF_TOKEN="))    ?.split("=")[1];
  if (!tokenFromForm || !tokenFromCookie || tokenFromForm !== tokenFromCookie) {    throw new Error("CSRF token mismatch");  }
  // Clear cookie after use (one-time use)  return {    clearCookie: `__Host-CSRF_TOKEN=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`,  };}
```

TypeScript

```
// Generate CSRF token when showing consent formfunction generateCSRFProtection() {  const token = crypto.randomUUID();  const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;  return { token, setCookie };}
// Validate CSRF token on form submissionfunction validateCSRFToken(formData: FormData, request: Request) {  const tokenFromForm = formData.get("csrf_token");  const cookieHeader = request.headers.get("Cookie") || "";  const tokenFromCookie = cookieHeader    .split(";")    .find((c) => c.trim().startsWith("__Host-CSRF_TOKEN="))    ?.split("=")[1];
  if (!tokenFromForm || !tokenFromCookie || tokenFromForm !== tokenFromCookie) {    throw new Error("CSRF token mismatch");  }
  // Clear cookie after use (one-time use)  return {    clearCookie: `__Host-CSRF_TOKEN=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`,  };}
```

Include the token as a hidden field in your consent form:

```
<input type="hidden" name="csrf_token" value="${csrfToken}" />
```

### Input sanitization

User-controlled content (client names, logos, URIs) can execute malicious scripts if not sanitized:

* [  JavaScript ](#tab-panel-6057)
* [  TypeScript ](#tab-panel-6058)

JavaScript

```
function sanitizeText(text) {  return text    .replace(/&/g, "&amp;")    .replace(/</g, "&lt;")    .replace(/>/g, "&gt;")    .replace(/"/g, "&quot;")    .replace(/'/g, "&#039;");}
function sanitizeUrl(url) {  if (!url) return "";  try {    const parsed = new URL(url);    // Only allow http/https - reject javascript:, data:, file:    if (!["http:", "https:"].includes(parsed.protocol)) {      return "";    }    return url;  } catch {    return "";  }}
// Always sanitize before renderingconst clientName = sanitizeText(client.clientName);const logoUrl = sanitizeText(sanitizeUrl(client.logoUri));
```

TypeScript

```
function sanitizeText(text: string): string {  return text    .replace(/&/g, "&amp;")    .replace(/</g, "&lt;")    .replace(/>/g, "&gt;")    .replace(/"/g, "&quot;")    .replace(/'/g, "&#039;");}
function sanitizeUrl(url: string): string {  if (!url) return "";  try {    const parsed = new URL(url);    // Only allow http/https - reject javascript:, data:, file:    if (!["http:", "https:"].includes(parsed.protocol)) {      return "";    }    return url;  } catch {    return "";  }}
// Always sanitize before renderingconst clientName = sanitizeText(client.clientName);const logoUrl = sanitizeText(sanitizeUrl(client.logoUri));
```

### Content Security Policy

CSP headers instruct browsers to block dangerous content:

* [  JavaScript ](#tab-panel-6055)
* [  TypeScript ](#tab-panel-6056)

JavaScript

```
function buildSecurityHeaders(setCookie, nonce) {  const cspDirectives = [    "default-src 'none'",    "script-src 'self'" + (nonce ? ` 'nonce-${nonce}'` : ""),    "style-src 'self' 'unsafe-inline'",    "img-src 'self' https:",    "font-src 'self'",    "form-action 'self'",    "frame-ancestors 'none'", // Prevent clickjacking    "base-uri 'self'",    "connect-src 'self'",  ].join("; ");
  return {    "Content-Security-Policy": cspDirectives,    "X-Frame-Options": "DENY",    "X-Content-Type-Options": "nosniff",    "Content-Type": "text/html; charset=utf-8",    "Set-Cookie": setCookie,  };}
```

TypeScript

```
function buildSecurityHeaders(setCookie: string, nonce?: string): HeadersInit {  const cspDirectives = [    "default-src 'none'",    "script-src 'self'" + (nonce ? ` 'nonce-${nonce}'` : ""),    "style-src 'self' 'unsafe-inline'",    "img-src 'self' https:",    "font-src 'self'",    "form-action 'self'",    "frame-ancestors 'none'", // Prevent clickjacking    "base-uri 'self'",    "connect-src 'self'",  ].join("; ");
  return {    "Content-Security-Policy": cspDirectives,    "X-Frame-Options": "DENY",    "X-Content-Type-Options": "nosniff",    "Content-Type": "text/html; charset=utf-8",    "Set-Cookie": setCookie,  };}
```

## State handling

Between the consent dialog and the OAuth callback, you need to ensure it is the same user. Use a state token stored in KV with a short expiration:

* [  JavaScript ](#tab-panel-6059)
* [  TypeScript ](#tab-panel-6060)

JavaScript

```
// Create state token before redirecting to upstream providerasync function createOAuthState(oauthReqInfo, kv) {  const stateToken = crypto.randomUUID();  await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), {    expirationTtl: 600, // 10 minutes  });  return { stateToken };}
// Bind state to browser session with a hashed cookieasync function bindStateToSession(stateToken) {  const encoder = new TextEncoder();  const hashBuffer = await crypto.subtle.digest(    "SHA-256",    encoder.encode(stateToken),  );  const hashHex = Array.from(new Uint8Array(hashBuffer))    .map((b) => b.toString(16).padStart(2, "0"))    .join("");
  return {    setCookie: `__Host-CONSENTED_STATE=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`,  };}
// Validate state in callbackasync function validateOAuthState(request, kv) {  const url = new URL(request.url);  const stateFromQuery = url.searchParams.get("state");
  if (!stateFromQuery) {    throw new Error("Missing state parameter");  }
  // Check state exists in KV  const storedData = await kv.get(`oauth:state:${stateFromQuery}`);  if (!storedData) {    throw new Error("Invalid or expired state");  }
  // Validate state matches session cookie  // ... (hash comparison logic)
  await kv.delete(`oauth:state:${stateFromQuery}`);  return JSON.parse(storedData);}
```

TypeScript

```
// Create state token before redirecting to upstream providerasync function createOAuthState(oauthReqInfo: AuthRequest, kv: KVNamespace) {  const stateToken = crypto.randomUUID();  await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), {    expirationTtl: 600, // 10 minutes  });  return { stateToken };}
// Bind state to browser session with a hashed cookieasync function bindStateToSession(stateToken: string) {  const encoder = new TextEncoder();  const hashBuffer = await crypto.subtle.digest(    "SHA-256",    encoder.encode(stateToken),  );  const hashHex = Array.from(new Uint8Array(hashBuffer))    .map((b) => b.toString(16).padStart(2, "0"))    .join("");
  return {    setCookie: `__Host-CONSENTED_STATE=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`,  };}
// Validate state in callbackasync function validateOAuthState(request: Request, kv: KVNamespace) {  const url = new URL(request.url);  const stateFromQuery = url.searchParams.get("state");
  if (!stateFromQuery) {    throw new Error("Missing state parameter");  }
  // Check state exists in KV  const storedData = await kv.get(`oauth:state:${stateFromQuery}`);  if (!storedData) {    throw new Error("Invalid or expired state");  }
  // Validate state matches session cookie  // ... (hash comparison logic)
  await kv.delete(`oauth:state:${stateFromQuery}`);  return JSON.parse(storedData);}
```

## Cookie security

### Why use the `__Host-` prefix?

The `__Host-` prefix prevents subdomain attacks, which is especially important on `*.workers.dev` domains:

* Must be set with `Secure` flag (HTTPS only)
* Must have `Path=/`
* Must not have a `Domain` attribute

Without `__Host-`, an attacker controlling `evil.workers.dev` could set cookies for your `mcp-server.workers.dev` domain.

### Multiple OAuth flows

If running multiple OAuth flows on the same domain, namespace your cookies:

```
__Host-CSRF_TOKEN_GITHUB__Host-CSRF_TOKEN_GOOGLE__Host-APPROVED_CLIENTS_GITHUB__Host-APPROVED_CLIENTS_GOOGLE
```

## Approved clients registry

Maintain a registry of approved client IDs per user to avoid showing the consent dialog repeatedly:

* [  JavaScript ](#tab-panel-6051)
* [  TypeScript ](#tab-panel-6052)

JavaScript

```
async function addApprovedClient(request, clientId, cookieSecret) {  const existingClients =    (await getApprovedClientsFromCookie(request, cookieSecret)) || [];  const updatedClients = [...new Set([...existingClients, clientId])];
  const payload = JSON.stringify(updatedClients);  const signature = await signData(payload, cookieSecret); // HMAC-SHA256  const cookieValue = `${signature}.${btoa(payload)}`;
  return `__Host-APPROVED_CLIENTS=${cookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=2592000`;}
```

TypeScript

```
async function addApprovedClient(  request: Request,  clientId: string,  cookieSecret: string,) {  const existingClients =    (await getApprovedClientsFromCookie(request, cookieSecret)) || [];  const updatedClients = [...new Set([...existingClients, clientId])];
  const payload = JSON.stringify(updatedClients);  const signature = await signData(payload, cookieSecret); // HMAC-SHA256  const cookieValue = `${signature}.${btoa(payload)}`;
  return `__Host-APPROVED_CLIENTS=${cookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=2592000`;}
```

When reading the cookie, verify the HMAC signature before trusting the data. If the client is not in the approved list, show the consent dialog.

## Security checklist

| Protection         | Purpose                          |
| ------------------ | -------------------------------- |
| CSRF tokens        | Prevent forged consent approvals |
| Input sanitization | Prevent XSS in consent dialogs   |
| CSP headers        | Block injected scripts           |
| State binding      | Prevent session fixation         |
| \_\_Host- cookies  | Prevent subdomain attacks        |
| HMAC signatures    | Verify cookie integrity          |

## Next steps

[ MCP authorization ](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/) OAuth and authentication for MCP servers. 

[ Build a remote MCP server ](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) Deploy MCP servers on Cloudflare. 

[ MCP security best practices ](https://modelcontextprotocol.io/specification/draft/basic/security%5Fbest%5Fpractices) Official MCP specification security guide.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/guides/securing-mcp-server/#page","headline":"Securing MCP servers · Cloudflare Agents docs","description":"Secure your MCP servers with OAuth 2.1, token validation, and scope-based access control on Cloudflare.","url":"https://developers.cloudflare.com/agents/model-context-protocol/guides/securing-mcp-server/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/guides/","name":"Guides"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/guides/securing-mcp-server/","name":"Securing MCP servers"}}]}
```

---

---
title: Test a Remote MCP Server
description: Test your remote MCP server using the MCP Inspector and compatible MCP clients.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Test a Remote MCP Server

Remote, authorized connections are an evolving part of the [Model Context Protocol (MCP) specification ↗](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization). Not all MCP clients support remote connections yet.

This guide will show you options for how to start using your remote MCP server with MCP clients that support remote connections. If you haven't yet created and deployed a remote MCP server, you should follow the [Build a Remote MCP Server](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) guide first.

## The Model Context Protocol (MCP) inspector

The [@modelcontextprotocol/inspector package ↗](https://github.com/modelcontextprotocol/inspector) is a visual testing tool for MCP servers.

1. Open a terminal and run the following command:  
Terminal window  
```  
npx @modelcontextprotocol/inspector  
```  
```  
🚀 MCP Inspector is up and running at:  http://localhost:5173/?MCP_PROXY_AUTH_TOKEN=46ab..cd3  
🌐 Opening browser...  
```  
The MCP Inspector will launch in your web browser. You can also launch it manually by opening a browser and going to `http://localhost:<PORT>`. Check the command output for the local port where MCP Inspector is running. In this example, MCP Inspector is served on port `5173`.
2. In the MCP inspector, enter the URL of your MCP server (for example, `http://localhost:8788/mcp`). Select **Connect**.  
You can connect to an MCP server running on your local machine or a remote MCP server running on Cloudflare.
3. If your server requires authentication, the connection will fail. To authenticate:

  1. In MCP Inspector, select **Open Auth settings**.
  2. Select **Quick OAuth Flow**.
  3. Once you have authenticated with the OAuth provider, you will be redirected back to MCP Inspector. Select **Connect**.

You should see the **List tools** button, which will list the tools that your MCP server exposes.

## Connect your remote MCP server to Cloudflare Workers AI Playground

Visit the [Workers AI Playground ↗](https://playground.ai.cloudflare.com/), enter your MCP server URL, and click "Connect". Once authenticated (if required), you should see your tools listed and they will be available to the AI model in the chat.

## Connect your remote MCP server to Claude Desktop via a local proxy

You can use the [mcp-remote local proxy ↗](https://www.npmjs.com/package/mcp-remote) to connect Claude Desktop to your remote MCP server. This lets you test what an interaction with your remote MCP server will be like with a real-world MCP client.

1. Open Claude Desktop and navigate to Settings -> Developer -> Edit Config. This opens the configuration file that controls which MCP servers Claude can access.
2. Replace the content with a configuration like this:

```
{  "mcpServers": {    "my-server": {      "command": "npx",      "args": ["mcp-remote", "http://my-mcp-server.my-account.workers.dev/mcp"]    }  }}
```

1. Save the file and restart Claude Desktop (command/ctrl + R). When Claude restarts, a browser window will open showing your OAuth login page. Complete the authorization flow to grant Claude access to your MCP server.

Once authenticated, you'll be able to see your tools by clicking the tools icon in the bottom right corner of Claude's interface.

## Connect your remote MCP server to Cursor

Connect [Cursor ↗](https://cursor.com/docs/context/mcp) to your remote MCP server by editing the project's `.cursor/mcp.json` file or a global `~/.cursor/mcp.json` file and adding the following configuration:

```
{  "mcpServers": {    "my-server": {      "url": "http://my-mcp-server.my-account.workers.dev/mcp"    }  }}
```

## Connect your remote MCP server to Windsurf

You can connect your remote MCP server to [Windsurf ↗](https://docs.windsurf.com) by editing the [mcp\_config.json file ↗](https://docs.windsurf.com/windsurf/cascade/mcp), and adding the following configuration:

```
{  "mcpServers": {    "my-server": {      "serverUrl": "http://my-mcp-server.my-account.workers.dev/mcp"    }  }}
```

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/guides/test-remote-mcp-server/#page","headline":"Test a Remote MCP Server · Cloudflare Agents docs","description":"Test your remote MCP server using the MCP Inspector and compatible MCP clients.","url":"https://developers.cloudflare.com/agents/model-context-protocol/guides/test-remote-mcp-server/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/guides/","name":"Guides"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/guides/test-remote-mcp-server/","name":"Test a Remote MCP Server"}}]}
```

---

---
title: Authorization
description: Add OAuth 2.1 authorization to your MCP server using Cloudflare Access, third-party providers, or your own identity system.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Authorization

When building a [Model Context Protocol (MCP) ↗](https://modelcontextprotocol.io) server, you need both a way to allow users to login (authentication) and allow them to grant the MCP client access to resources on their account (authorization).

The Model Context Protocol uses [a subset of OAuth 2.1 for authorization ↗](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization). OAuth allows your users to grant limited access to resources, without them having to share API keys or other credentials.

Cloudflare provides an [OAuth Provider Library ↗](https://github.com/cloudflare/workers-oauth-provider) that implements the provider side of the OAuth 2.1 protocol, allowing you to easily add authorization to your MCP server.

You can use the OAuth Provider Library in four ways:

1. Use Cloudflare Access as an OAuth provider.
2. Integrate directly with a third-party OAuth provider, such as GitHub or Google.
3. Integrate with your own OAuth provider, including authorization-as-a-service providers you might already rely on, such as Stytch, Auth0, or WorkOS.
4. Your Worker handles authorization and authentication itself. Your MCP server, running on Cloudflare, handles the complete OAuth flow.

The following sections describe each of these options and link to runnable code examples for each.

## Authorization options

### (1) Cloudflare Access OAuth provider

Cloudflare Access allows you to add Single Sign-On (SSO) functionality to your MCP server. Users authenticate to your MCP server using a [configured identity provider](https://developers.cloudflare.com/cloudflare-one/integrations/identity-providers/) or a [one-time PIN](https://developers.cloudflare.com/cloudflare-one/integrations/identity-providers/one-time-pin/), and they are only granted access if their identity matches your [Access policies](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/).

To deploy an [example MCP server ↗](https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-cf-access) with Cloudflare Access as the OAuth provider, refer to [Secure MCP servers with Access for SaaS](https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/secure-mcp-servers/).

### (2) Third-party OAuth Provider

The [OAuth Provider Library ↗](https://github.com/cloudflare/workers-oauth-provider) can be configured to use a third-party OAuth provider, such as GitHub or Google. You can see a complete example of this in the [GitHub example](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/#add-authentication).

When you use a third-party OAuth provider, you must provide a handler to the `OAuthProvider` that implements the OAuth flow for the third-party provider.

TypeScript

```
import MyAuthHandler from "./auth-handler";
export default new OAuthProvider({  apiRoute: "/mcp",  // Your MCP server:  apiHandler: MyMCPServer.serve("/mcp"),  // Replace this handler with your own handler for authentication and authorization with the third-party provider:  defaultHandler: MyAuthHandler,  authorizeEndpoint: "/authorize",  tokenEndpoint: "/token",  clientRegistrationEndpoint: "/register",});
```

Note that as [defined in the Model Context Protocol specification ↗](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-flow-steps) when you use a third-party OAuth provider, the MCP Server (your Worker) generates and issues its own token to the MCP client:

sequenceDiagram
    participant B as User-Agent (Browser)
    participant C as MCP Client
    participant M as MCP Server (your Worker)
    participant T as Third-Party Auth Server

    C->>M: Initial OAuth Request
    M->>B: Redirect to Third-Party /authorize
    B->>T: Authorization Request
    Note over T: User authorizes
    T->>B: Redirect to MCP Server callback
    B->>M: Authorization code
    M->>T: Exchange code for token
    T->>M: Third-party access token
    Note over M: Generate bound MCP token
    M->>B: Redirect to MCP Client callback
    B->>C: MCP authorization code
    C->>M: Exchange code for token
    M->>C: MCP access token

Read the docs for the [Workers OAuth Provider Library ↗](https://github.com/cloudflare/workers-oauth-provider) for more details.

### (3) Bring your own OAuth Provider

If your application already implements an OAuth Provider itself, or you use an authorization-as-a-service provider, you can use this in the same way that you would use a third-party OAuth provider, described above in [(2) Third-party OAuth Provider](#2-third-party-oauth-provider).

You can use the auth provider to:

* Allow users to authenticate to your MCP server through email, social logins, SSO (single sign-on), and MFA (multi-factor authentication).
* Define scopes and permissions that directly map to your MCP tools.
* Present users with a consent page corresponding with the requested permissions.
* Enforce the permissions so that agents can only invoke permitted tools.

#### Stytch

Get started with a [remote MCP server that uses Stytch ↗](https://stytch.com/docs/guides/connected-apps/mcp-servers) to allow users to sign in with email, Google login or enterprise SSO and authorize their AI agent to view and manage their company's OKRs on their behalf. Stytch will handle restricting the scopes granted to the AI agent based on the user's role and permissions within their organization. When authorizing the MCP Client, each user will see a consent page that outlines the permissions that the agent is requesting that they are able to grant based on their role.

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/ai/tree/main/demos/mcp-stytch-b2b-okr-manager)

For more consumer use cases, deploy a remote MCP server for a To Do app that uses Stytch for authentication and MCP client authorization. Users can sign in with email and immediately access the To Do lists associated with their account, and grant access to any AI assistant to help them manage their tasks.

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/ai/tree/main/demos/mcp-stytch-consumer-todo-list)

#### Auth0

Get started with a remote MCP server that uses Auth0 to authenticate users through email, social logins, or enterprise SSO to interact with their todos and personal data through AI agents. The MCP server securely connects to API endpoints on behalf of users, showing exactly which resources the agent will be able to access once it gets consent from the user. In this implementation, access tokens are automatically refreshed during long running interactions.

To set it up, first deploy the protected API endpoint:

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-auth0/todos-api)

Then, deploy the MCP server that handles authentication through Auth0 and securely connects AI agents to your API endpoint.

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-auth0/mcp-auth0-oidc)

#### WorkOS

Get started with a remote MCP server that uses WorkOS's AuthKit to authenticate users and manage the permissions granted to AI agents. In this example, the MCP server dynamically exposes tools based on the user's role and access rights. All authenticated users get access to the `add` tool, but only users who have been assigned the `image_generation` permission in WorkOS can grant the AI agent access to the image generation tool. This showcases how MCP servers can conditionally expose capabilities to AI agents based on the authenticated user's role and permission.

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-authkit)

#### Descope

Get started with a remote MCP server that uses [Descope ↗](https://www.descope.com/) Inbound Apps to authenticate and authorize users (for example, email, social login, SSO) to interact with their data through AI agents. Leverage Descope custom scopes to define and manage permissions for more fine-grained control.

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-server-descope-auth)

### (4) Your MCP Server handles authorization and authentication itself

Your MCP Server, using the [OAuth Provider Library ↗](https://github.com/cloudflare/workers-oauth-provider), can handle the complete OAuth authorization flow, without any third-party involvement.

The [Workers OAuth Provider Library ↗](https://github.com/cloudflare/workers-oauth-provider) is a Cloudflare Worker that implements a [fetch() handler](https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/), and handles incoming requests to your MCP server.

You provide your own handlers for your MCP Server's API, and authentication and authorization logic, and URI paths for the OAuth endpoints, as shown below:

TypeScript

```
export default new OAuthProvider({  apiRoute: "/mcp",  // Your MCP server:  apiHandler: MyMCPServer.serve("/mcp"),  // Your handler for authentication and authorization:  defaultHandler: MyAuthHandler,  authorizeEndpoint: "/authorize",  tokenEndpoint: "/token",  clientRegistrationEndpoint: "/register",});
```

Refer to the [getting started example](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) for a complete example of the `OAuthProvider` in use, with a mock authentication flow.

The authorization flow in this case works like this:

sequenceDiagram
    participant B as User-Agent (Browser)
    participant C as MCP Client
    participant M as MCP Server (your Worker)

    C->>M: MCP Request
    M->>C: HTTP 401 Unauthorized
    Note over C: Generate code_verifier and code_challenge
    C->>B: Open browser with authorization URL + code_challenge
    B->>M: GET /authorize
    Note over M: User logs in and authorizes
    M->>B: Redirect to callback URL with auth code
    B->>C: Callback with authorization code
    C->>M: Token Request with code + code_verifier
    M->>C: Access Token (+ Refresh Token)
    C->>M: MCP Request with Access Token
    Note over C,M: Begin standard MCP message exchange

Remember — [authentication is different from authorization ↗](https://www.cloudflare.com/learning/access-management/authn-vs-authz/). Your MCP Server can handle authorization itself, while still relying on an external authentication service to first authenticate users. The [example](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) in getting started provides a mock authentication flow. You will need to implement your own authentication handler — either handling authentication yourself, or using an external authentication services.

## Using authentication context in tools

When a user authenticates through the OAuth Provider, their identity information is available inside your tools. How you access it depends on whether you use `McpAgent` or `createMcpHandler`.

### With McpAgent

The third type parameter on `McpAgent` defines the shape of the authentication context. Access it via `this.props` inside `init()` and tool handlers.

TypeScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
type AuthContext = {  claims: { sub: string; name: string; email: string };  permissions: string[];};
export class MyMCP extends McpAgent<Env, unknown, AuthContext> {  server = new McpServer({ name: "Auth Demo", version: "1.0.0" });
  async init() {    this.server.tool("whoami", "Get the current user", {}, async () => ({      content: [{ type: "text", text: `Hello, ${this.props.claims.name}!` }],    }));  }}
```

### With createMcpHandler

Use `getMcpAuthContext()` to access the same information from within a tool handler. This uses `AsyncLocalStorage` under the hood.

TypeScript

```
import { createMcpHandler, getMcpAuthContext } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
function createServer() {  const server = new McpServer({ name: "Auth Demo", version: "1.0.0" });
  server.tool("whoami", "Get the current user", {}, async () => {    const auth = getMcpAuthContext();    const name = (auth?.props?.name as string) ?? "anonymous";    return {      content: [{ type: "text", text: `Hello, ${name}!` }],    };  });
  return server;}
```

## Permission-based tool access

You can control which tools are available based on user permissions. There are two approaches: check permissions inside the tool handler, or conditionally register tools.

TypeScript

```
export class MyMCP extends McpAgent<Env, unknown, AuthContext> {  server = new McpServer({ name: "Permissions Demo", version: "1.0.0" });
  async init() {    this.server.tool("publicTool", "Available to all users", {}, async () => ({      content: [{ type: "text", text: "Public result" }],    }));
    this.server.tool(      "adminAction",      "Requires admin permission",      {},      async () => {        if (!this.props.permissions?.includes("admin")) {          return {            content: [              { type: "text", text: "Permission denied: requires admin" },            ],          };        }        return {          content: [{ type: "text", text: "Admin action completed" }],        };      },    );
    if (this.props.permissions?.includes("special_feature")) {      this.server.tool("specialTool", "Special feature", {}, async () => ({        content: [{ type: "text", text: "Special feature result" }],      }));    }  }}
```

Checking inside the handler returns an error message to the LLM, which can explain the denial to the user. Conditionally registering tools means the LLM never sees tools the user cannot access — it cannot attempt to call them at all.

## Next steps

[ Workers OAuth Provider ](https://github.com/cloudflare/workers-oauth-provider) OAuth provider library for Workers. 

[ MCP portals ](https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/mcp-portals/) Set up MCP portals to provide governance and security.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/#page","headline":"Authorization · Cloudflare Agents docs","description":"Add OAuth 2.1 authorization to your MCP server using Cloudflare Access, third-party providers, or your own identity system.","url":"https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/protocol/","name":"Protocol"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/protocol/authorization/","name":"Authorization"}}]}
```

---

---
title: MCP governance
description: Control which MCP servers your organization uses and enforce access policies with Cloudflare Access.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# MCP governance

Model Context Protocol (MCP) allows Large Language Models (LLMs) to interact with proprietary data and internal tools. However, as MCP adoption grows, organizations face security risks from "Shadow MCP", where employees run unmanaged local MCP servers against sensitive internal resources. MCP governance means that administrators have control over which MCP servers are used in the organization, who can use them, and under what conditions.

## MCP server portals

Cloudflare Access provides a centralized governance layer for MCP, allowing you to vet, authorize, and audit every interaction between users and MCP servers.

The [MCP server portal](https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/mcp-portals/) serves as the administrative hub for governance. From this portal, administrators can manage both third-party and internal MCP servers and define policies for:

* **Identity**: Which users or groups are authorized to access specific MCP servers.
* **Conditions**: The security posture (for example, device health or location) required for access.
* **Scope**: Which specific tools within an MCP server are authorized for use.

Cloudflare Access logs MCP server requests and tool executions made through the portal, providing administrators with visibility into MCP usage across the organization.

## Remote MCP servers

To maintain a modern security posture, Cloudflare recommends the use of [remote MCP servers](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) over local installations. Running MCP servers locally introduces risks similar to unmanaged [shadow IT ↗](https://www.cloudflare.com/learning/access-management/what-is-shadow-it/), making it difficult to audit data flow or verify the integrity of the server code. Remote MCP servers give administrators visibility into what servers are being used, along with the ability to control who access them and what tools are authorized for employee use.

You can [build your remote MCP servers](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) directly on Cloudflare Workers. When both your [MCP server portal](#mcp-server-portals) and remote MCP servers run on Cloudflare's network, requests stay on the same infrastructure, minimizing latency and maximizing performance.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/protocol/governance/#page","headline":"MCP governance · Cloudflare Agents docs","description":"Control which MCP servers your organization uses and enforce access policies with Cloudflare Access.","url":"https://developers.cloudflare.com/agents/model-context-protocol/protocol/governance/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/protocol/","name":"Protocol"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/protocol/governance/","name":"MCP governance"}}]}
```

---

---
title: Tools
description: Define, register, and manage MCP tools that expose server-side functions for AI agents to call.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Tools

MCP tools are functions that an [MCP server](https://developers.cloudflare.com/agents/model-context-protocol/) exposes for clients to call. When an LLM decides it needs to take an action — look up data, run a calculation, call an API — it invokes a tool. The MCP server executes the tool and returns the result.

Tools are defined using the `@modelcontextprotocol/sdk` package. The Agents SDK handles transport and lifecycle; the tool definitions are the same regardless of whether you use [createMcpHandler](https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/) or [McpAgent](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/).

Experimental WebMCP adapter

The Agents SDK also includes the experimental `agents/experimental/webmcp` adapter for bridging `McpAgent` tools to Chrome's native `navigator.modelContext` API. This API is under active development and may change between releases.

[ WebMCP example ](https://github.com/cloudflare/agents/tree/main/examples/webmcp) Bridge MCP tools from a Cloudflare McpAgent into Chrome's experimental WebMCP API. 

## Defining tools

Use `server.tool()` to register a tool on an `McpServer` instance. Each tool has a name, a description (used by the LLM to decide when to call it), an input schema defined with [Zod ↗](https://zod.dev), and a handler function.

* [  JavaScript ](#tab-panel-6061)
* [  TypeScript ](#tab-panel-6062)

JavaScript

```
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
function createServer() {  const server = new McpServer({ name: "Math", version: "1.0.0" });
  server.tool(    "add",    "Add two numbers together",    { a: z.number(), b: z.number() },    async ({ a, b }) => ({      content: [{ type: "text", text: String(a + b) }],    }),  );
  return server;}
```

TypeScript

```
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
function createServer() {  const server = new McpServer({ name: "Math", version: "1.0.0" });
  server.tool(    "add",    "Add two numbers together",    { a: z.number(), b: z.number() },    async ({ a, b }) => ({      content: [{ type: "text", text: String(a + b) }],    }),  );
  return server;}
```

The tool handler receives the validated input and must return an object with a `content` array. Each content item has a `type` (typically `"text"`) and the corresponding data.

## Tool results

Tool results are returned as an array of content parts. The most common type is `text`, but you can also return images and embedded resources.

* [  JavaScript ](#tab-panel-6063)
* [  TypeScript ](#tab-panel-6064)

JavaScript

```
server.tool(  "lookup",  "Look up a user by ID",  { userId: z.string() },  async ({ userId }) => {    const user = await db.getUser(userId);
    if (!user) {      return {        isError: true,        content: [{ type: "text", text: `User ${userId} not found` }],      };    }
    return {      content: [{ type: "text", text: JSON.stringify(user, null, 2) }],    };  },);
```

TypeScript

```
server.tool(  "lookup",  "Look up a user by ID",  { userId: z.string() },  async ({ userId }) => {    const user = await db.getUser(userId);
    if (!user) {      return {        isError: true,        content: [{ type: "text", text: `User ${userId} not found` }],      };    }
    return {      content: [{ type: "text", text: JSON.stringify(user, null, 2) }],    };  },);
```

Set `isError: true` to signal that the tool call failed. The LLM receives the error message and can decide how to proceed.

## Tool descriptions

The `description` parameter is critical — it is what the LLM reads to decide whether and when to call your tool. Write descriptions that are:

* **Specific** about what the tool does: "Get the current weather for a city" is better than "Weather tool"
* **Clear about inputs**: "Requires a city name as a string" helps the LLM format the call correctly
* **Honest about limitations**: "Only supports US cities" prevents the LLM from calling it with unsupported inputs

## Input validation with Zod

Tool inputs are defined as Zod schemas and validated automatically before the handler runs. Use Zod's `.describe()` method to give the LLM context about each parameter.

* [  JavaScript ](#tab-panel-6067)
* [  TypeScript ](#tab-panel-6068)

JavaScript

```
server.tool(  "search",  "Search for documents by query",  {    query: z.string().describe("The search query"),    limit: z      .number()      .min(1)      .max(100)      .default(10)      .describe("Maximum number of results to return"),    category: z      .enum(["docs", "blog", "api"])      .optional()      .describe("Filter by content category"),  },  async ({ query, limit, category }) => {    const results = await searchIndex(query, { limit, category });    return {      content: [{ type: "text", text: JSON.stringify(results) }],    };  },);
```

TypeScript

```
server.tool(  "search",  "Search for documents by query",  {    query: z.string().describe("The search query"),    limit: z      .number()      .min(1)      .max(100)      .default(10)      .describe("Maximum number of results to return"),    category: z      .enum(["docs", "blog", "api"])      .optional()      .describe("Filter by content category"),  },  async ({ query, limit, category }) => {    const results = await searchIndex(query, { limit, category });    return {      content: [{ type: "text", text: JSON.stringify(results) }],    };  },);
```

## Using tools with `createMcpHandler`

For stateless MCP servers, define tools inside a factory function and pass the server to [createMcpHandler](https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/):

* [  JavaScript ](#tab-panel-6065)
* [  TypeScript ](#tab-panel-6066)

JavaScript

```
import { createMcpHandler } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
function createServer() {  const server = new McpServer({ name: "My Tools", version: "1.0.0" });
  server.tool("ping", "Check if the server is alive", {}, async () => ({    content: [{ type: "text", text: "pong" }],  }));
  return server;}
export default {  fetch: (request, env, ctx) => {    const server = createServer();    return createMcpHandler(server)(request, env, ctx);  },};
```

TypeScript

```
import { createMcpHandler } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
function createServer() {  const server = new McpServer({ name: "My Tools", version: "1.0.0" });
  server.tool("ping", "Check if the server is alive", {}, async () => ({    content: [{ type: "text", text: "pong" }],  }));
  return server;}
export default {  fetch: (request: Request, env: Env, ctx: ExecutionContext) => {    const server = createServer();    return createMcpHandler(server)(request, env, ctx);  },} satisfies ExportedHandler<Env>;
```

## Using tools with `McpAgent`

For stateful MCP servers, define tools in the `init()` method of an [McpAgent](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/). Tools have access to the agent instance via `this`, which means they can read and write state.

* [  JavaScript ](#tab-panel-6069)
* [  TypeScript ](#tab-panel-6070)

JavaScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
export class MyMCP extends McpAgent {  server = new McpServer({ name: "Stateful Tools", version: "1.0.0" });
  async init() {    this.server.tool(      "incrementCounter",      "Increment and return a counter",      {},      async () => {        const count = (this.state?.count ?? 0) + 1;        this.setState({ count });        return {          content: [{ type: "text", text: `Counter: ${count}` }],        };      },    );  }}
```

TypeScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
export class MyMCP extends McpAgent {  server = new McpServer({ name: "Stateful Tools", version: "1.0.0" });
  async init() {    this.server.tool(      "incrementCounter",      "Increment and return a counter",      {},      async () => {        const count = (this.state?.count ?? 0) + 1;        this.setState({ count });        return {          content: [{ type: "text", text: `Counter: ${count}` }],        };      },    );  }}
```

## Next steps

[ Build a remote MCP server ](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) Step-by-step guide to deploying an MCP server on Cloudflare. 

[ createMcpHandler API ](https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/) Reference for stateless MCP servers. 

[ McpAgent API ](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/) Reference for stateful MCP servers. 

[ MCP authorization ](https://developers.cloudflare.com/agents/model-context-protocol/protocol/authorization/) Add OAuth authentication to your MCP server.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/protocol/tools/#page","headline":"Tools · Cloudflare Agents docs","description":"Define, register, and manage MCP tools that expose server-side functions for AI agents to call.","url":"https://developers.cloudflare.com/agents/model-context-protocol/protocol/tools/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/protocol/","name":"Protocol"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/protocol/tools/","name":"Tools"}}]}
```

---

---
title: Transport
description: Configure Streamable HTTP transport for remote MCP servers built with the Agents SDK.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Transport

The Model Context Protocol (MCP) specification defines two standard [transport mechanisms ↗](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) for communication between clients and servers:

1. **stdio** — Communication over standard in and standard out, designed for local MCP connections.
2. **Streamable HTTP** — The standard transport method for remote MCP connections, [introduced ↗](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) in March 2025\. It uses a single HTTP endpoint for bidirectional messaging.

Note

Server-Sent Events (SSE) was previously used for remote MCP connections but has been deprecated in favor of Streamable HTTP. If you need SSE support for legacy clients, use the [McpAgent](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/) class.

MCP servers built with the [Agents SDK](https://developers.cloudflare.com/agents) use [createMcpHandler](https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/) to handle Streamable HTTP transport.

## Implementing remote MCP transport

Use [createMcpHandler](https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/) to create an MCP server that handles Streamable HTTP transport. This is the recommended approach for new MCP servers.

#### Get started quickly

You can use the "Deploy to Cloudflare" button to create a remote MCP server.

[![Deploy to Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/agents/tree/main/examples/mcp-worker)

#### Remote MCP server (without authentication)

Create an MCP server using `createMcpHandler`. View the [complete example on GitHub ↗](https://github.com/cloudflare/agents/tree/main/examples/mcp-worker).

* [  JavaScript ](#tab-panel-6081)
* [  TypeScript ](#tab-panel-6082)

JavaScript

```
import { createMcpHandler } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
function createServer() {  const server = new McpServer({    name: "My MCP Server",    version: "1.0.0",  });
  server.registerTool(    "hello",    {      description: "Returns a greeting message",      inputSchema: { name: z.string().optional() },    },    async ({ name }) => {      return {        content: [{ text: `Hello, ${name ?? "World"}!`, type: "text" }],      };    },  );
  return server;}
export default {  fetch: (request, env, ctx) => {    // Create a new server instance per request    const server = createServer();    return createMcpHandler(server)(request, env, ctx);  },};
```

TypeScript

```
import { createMcpHandler } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
function createServer() {  const server = new McpServer({    name: "My MCP Server",    version: "1.0.0",  });
  server.registerTool(    "hello",    {      description: "Returns a greeting message",      inputSchema: { name: z.string().optional() },    },    async ({ name }) => {      return {        content: [{ text: `Hello, ${name ?? "World"}!`, type: "text" }],      };    },  );
  return server;}
export default {  fetch: (request: Request, env: Env, ctx: ExecutionContext) => {    // Create a new server instance per request    const server = createServer();    return createMcpHandler(server)(request, env, ctx);  },} satisfies ExportedHandler<Env>;
```

#### MCP server with authentication

If your MCP server implements authentication & authorization using the [Workers OAuth Provider ↗](https://github.com/cloudflare/workers-oauth-provider) library, use `createMcpHandler` with the `apiRoute` and `apiHandler` properties. View the [complete example on GitHub ↗](https://github.com/cloudflare/agents/tree/main/examples/mcp-worker-authenticated).

* [  JavaScript ](#tab-panel-6071)
* [  TypeScript ](#tab-panel-6072)

JavaScript

```
export default new OAuthProvider({  apiRoute: "/mcp",  apiHandler: {    fetch: (request, env, ctx) => {      // Create a new server instance per request      const server = createServer();      return createMcpHandler(server)(request, env, ctx);    },  },  // ... other OAuth configuration});
```

TypeScript

```
export default new OAuthProvider({  apiRoute: "/mcp",  apiHandler: {    fetch: (request: Request, env: Env, ctx: ExecutionContext) => {      // Create a new server instance per request      const server = createServer();      return createMcpHandler(server)(request, env, ctx);    },  },  // ... other OAuth configuration});
```

### Stateful MCP servers

If your MCP server needs to maintain state across requests, use `createMcpHandler` with a `WorkerTransport` inside an [Agent](https://developers.cloudflare.com/agents/) class. This allows you to persist session state in Durable Object storage and use advanced MCP features like [elicitation ↗](https://modelcontextprotocol.io/specification/draft/client/elicitation) and [sampling ↗](https://modelcontextprotocol.io/specification/draft/client/sampling).

See [Stateful MCP Servers](https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/#stateful-mcp-servers) for implementation details.

Streamable HTTP streams are resumable: configure an `EventStore` so clients can reconnect with a `Last-Event-ID` header and replay missed events, keeping in-flight tool calls alive across the edge idle-stream watchdog. `DurableObjectEventStore` is exported from `agents/mcp` for stateful `WorkerTransport` callers. Refer to [McpAgent: Stream resumability](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/#stream-resumability).

## RPC transport

The **RPC transport** is designed for internal applications where your MCP server and agent are both running on Cloudflare — they can even run in the same Worker. It sends JSON-RPC messages directly over Cloudflare's [RPC bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/) without going over the public internet.

* **Faster** — no network overhead, direct function calls between Durable Objects
* **Simpler** — no HTTP endpoints, no connection management
* **Internal only** — perfect for agents calling MCP servers within the same Worker

RPC transport does not support authentication. Use Streamable HTTP for external connections that require OAuth.

### Connecting an Agent to an McpAgent via RPC

#### 1\. Define your MCP server

Create your `McpAgent` with the tools you want to expose:

* [  JavaScript ](#tab-panel-6083)
* [  TypeScript ](#tab-panel-6084)

JavaScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
export class MyMCP extends McpAgent {  server = new McpServer({ name: "MyMCP", version: "1.0.0" });  initialState = { counter: 0 };
  async init() {    this.server.tool(      "add",      "Add to the counter",      { amount: z.number() },      async ({ amount }) => {        this.setState({ counter: this.state.counter + amount });        return {          content: [            {              type: "text",              text: `Added ${amount}, total is now ${this.state.counter}`,            },          ],        };      },    );  }}
```

TypeScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
type State = { counter: number };
export class MyMCP extends McpAgent<Env, State> {  server = new McpServer({ name: "MyMCP", version: "1.0.0" });  initialState: State = { counter: 0 };
  async init() {    this.server.tool(      "add",      "Add to the counter",      { amount: z.number() },      async ({ amount }) => {        this.setState({ counter: this.state.counter + amount });        return {          content: [            {              type: "text",              text: `Added ${amount}, total is now ${this.state.counter}`,            },          ],        };      },    );  }}
```

#### 2\. Connect your Agent to the MCP server

In your `Agent`, call `addMcpServer()` with the Durable Object binding in `onStart()`:

* [  JavaScript ](#tab-panel-6077)
* [  TypeScript ](#tab-panel-6078)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";
export class Chat extends AIChatAgent {  async onStart() {    // Pass the DO namespace binding directly    await this.addMcpServer("my-mcp", this.env.MyMCP);  }
  async onChatMessage(onFinish) {    const allTools = this.mcp.getAITools();
    const result = streamText({      model,      tools: allTools,      // ...    });
    return createUIMessageStreamResponse({ stream: result });  }}
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";
export class Chat extends AIChatAgent<Env> {  async onStart(): Promise<void> {    // Pass the DO namespace binding directly    await this.addMcpServer("my-mcp", this.env.MyMCP);  }
  async onChatMessage(onFinish) {    const allTools = this.mcp.getAITools();
    const result = streamText({      model,      tools: allTools,      // ...    });
    return createUIMessageStreamResponse({ stream: result });  }}
```

RPC connections are automatically restored after Durable Object hibernation, just like HTTP connections. The binding name and props are persisted to storage so the connection can be re-established without any extra code.

For RPC transport, if `addMcpServer` is called with a name that already has an active connection, the existing connection is returned instead of creating a duplicate. For HTTP transport, deduplication matches on both server name and URL (refer to [MCP Client API](https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/) for details). This makes it safe to call in `onStart()`.

#### 3\. Configure Durable Object bindings

In your `wrangler.jsonc`, define bindings for both Durable Objects:

JSONC

```
{  "durable_objects": {    "bindings": [      { "name": "Chat", "class_name": "Chat" },      { "name": "MyMCP", "class_name": "MyMCP" }    ]  },  "migrations": [    {      "new_sqlite_classes": ["MyMCP", "Chat"],      "tag": "v1"    }  ]}
```

#### 4\. Set up your Worker fetch handler

Route requests to your Chat agent:

* [  JavaScript ](#tab-panel-6075)
* [  TypeScript ](#tab-panel-6076)

JavaScript

```
import { routeAgentRequest } from "agents";
export default {  async fetch(request, env, ctx) {    const url = new URL(request.url);
    // Optionally expose the MCP server via HTTP as well    if (url.pathname.startsWith("/mcp")) {      return MyMCP.serve("/mcp").fetch(request, env, ctx);    }
    const response = await routeAgentRequest(request, env);    if (response) return response;
    return new Response("Not found", { status: 404 });  },};
```

TypeScript

```
import { routeAgentRequest } from "agents";
export default {  async fetch(request: Request, env: Env, ctx: ExecutionContext) {    const url = new URL(request.url);
    // Optionally expose the MCP server via HTTP as well    if (url.pathname.startsWith("/mcp")) {      return MyMCP.serve("/mcp").fetch(request, env, ctx);    }
    const response = await routeAgentRequest(request, env);    if (response) return response;
    return new Response("Not found", { status: 404 });  },} satisfies ExportedHandler<Env>;
```

### Passing props to the MCP server

Since RPC transport does not have an OAuth flow, you can pass user context directly as props:

* [  JavaScript ](#tab-panel-6073)
* [  TypeScript ](#tab-panel-6074)

JavaScript

```
await this.addMcpServer("my-mcp", this.env.MyMCP, {  props: { userId: "user-123", role: "admin" },});
```

TypeScript

```
await this.addMcpServer("my-mcp", this.env.MyMCP, {  props: { userId: "user-123", role: "admin" },});
```

Your `McpAgent` can then access these props:

* [  JavaScript ](#tab-panel-6079)
* [  TypeScript ](#tab-panel-6080)

JavaScript

```
export class MyMCP extends McpAgent {  async init() {    this.server.tool("whoami", "Get current user info", {}, async () => {      const userId = this.props?.userId || "anonymous";      const role = this.props?.role || "guest";
      return {        content: [{ type: "text", text: `User ID: ${userId}, Role: ${role}` }],      };    });  }}
```

TypeScript

```
export class MyMCP extends McpAgent<  Env,  State,  { userId?: string; role?: string }> {  async init() {    this.server.tool("whoami", "Get current user info", {}, async () => {      const userId = this.props?.userId || "anonymous";      const role = this.props?.role || "guest";
      return {        content: [          { type: "text", text: `User ID: ${userId}, Role: ${role}` },        ],      };    });  }}
```

Props are type-safe (TypeScript extracts the Props type from your `McpAgent` generic), persistent (stored in Durable Object storage), and available immediately before any tool calls are made.

### Configuring RPC transport server timeout

The RPC transport has a configurable timeout for waiting for tool responses. By default, the server waits **60 seconds** for a tool handler to respond. You can customize this by overriding `getRpcTransportOptions()` in your `McpAgent`:

* [  JavaScript ](#tab-panel-6085)
* [  TypeScript ](#tab-panel-6086)

JavaScript

```
export class MyMCP extends McpAgent {  server = new McpServer({ name: "MyMCP", version: "1.0.0" });
  getRpcTransportOptions() {    return { timeout: 120000 }; // 2 minutes  }
  async init() {    this.server.tool(      "long-running-task",      "A tool that takes a while",      { input: z.string() },      async ({ input }) => {        await longRunningOperation(input);        return {          content: [{ type: "text", text: "Task completed" }],        };      },    );  }}
```

TypeScript

```
export class MyMCP extends McpAgent<Env, State> {  server = new McpServer({ name: "MyMCP", version: "1.0.0" });
  protected getRpcTransportOptions() {    return { timeout: 120000 }; // 2 minutes  }
  async init() {    this.server.tool(      "long-running-task",      "A tool that takes a while",      { input: z.string() },      async ({ input }) => {        await longRunningOperation(input);        return {          content: [{ type: "text", text: "Task completed" }],        };      },    );  }}
```

## Choosing a transport

| Transport           | Use when                              | Pros                                     | Cons                                  |
| ------------------- | ------------------------------------- | ---------------------------------------- | ------------------------------------- |
| **Streamable HTTP** | External MCP servers, production apps | Standard protocol, secure, supports auth | Slight network overhead               |
| **RPC**             | Internal agents on Cloudflare         | Fastest, simplest setup                  | No auth, Durable Object bindings only |
| **SSE**             | Legacy compatibility                  | Backwards compatible                     | Deprecated, use Streamable HTTP       |

### Migrating from McpAgent

If you have an existing MCP server using the `McpAgent` class:

* **Not using state?** Replace your `McpAgent` class with `McpServer` from `@modelcontextprotocol/sdk` and use `createMcpHandler(server)` in a Worker `fetch` handler.
* **Using state?** Use `createMcpHandler` with a `WorkerTransport` inside an [Agent](https://developers.cloudflare.com/agents/) class. See [Stateful MCP Servers](https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/#stateful-mcp-servers) for details.
* **Need SSE support?** Continue using `McpAgent` with `serveSSE()` for legacy client compatibility. See the [McpAgent API reference](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/).

### Testing with MCP clients

You can test your MCP server using an MCP client that supports remote connections, or use [mcp-remote ↗](https://www.npmjs.com/package/mcp-remote), an adapter that lets MCP clients that only support local connections work with remote MCP servers.

Follow [this guide](https://developers.cloudflare.com/agents/model-context-protocol/guides/test-remote-mcp-server/) for instructions on how to connect to your remote MCP server to Claude Desktop, Cursor, Windsurf, and other MCP clients.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/model-context-protocol/protocol/transport/#page","headline":"Transport · Cloudflare Agents docs","description":"Configure Streamable HTTP transport for remote MCP servers built with the Agents SDK.","url":"https://developers.cloudflare.com/agents/model-context-protocol/protocol/transport/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["MCP"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/model-context-protocol/","name":"Model Context Protocol (MCP)"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/model-context-protocol/protocol/","name":"Protocol"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/model-context-protocol/protocol/transport/","name":"Transport"}}]}
```

---

---
title: Autonomous responses
description: Send server-initiated messages and trigger LLM responses from Cloudflare Agents without user action.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Autonomous responses

Send messages and trigger LLM responses from the server without a human action. Use this for scheduled follow-ups, queue processing, email-triggered responses, and autonomous agent workflows.

## Overview

In a typical chat flow, the user sends a message and the agent responds. But agents often need to act on their own — a scheduled reminder fires, a webhook arrives, a workflow completes, or the agent decides to continue after inspecting its own response.

The key primitives:

| Primitive         | Role                                                                             |
| ----------------- | -------------------------------------------------------------------------------- |
| saveMessages      | Inject a message and trigger the LLM — the server-side equivalent of sendMessage |
| submitMessages    | Durably accept a Think turn for async execution and inspect it later             |
| startFiber        | Durably accept application-owned side effects around a turn                      |
| persistMessages   | Store messages without triggering a response — for injecting context silently    |
| onChatResponse    | React when any response completes, including ones you did not initiate           |
| isServerStreaming | Client-side flag: true when a server-initiated stream is active                  |

### `saveMessages` vs `persistMessages`

`saveMessages` persists messages to SQLite **and** triggers `onChatMessage` for a new LLM response. It is awaitable — after it returns, the LLM has responded and the message is persisted.

`persistMessages` stores messages and broadcasts them to connected clients, but does **not** trigger a model turn. Use it when you want to inject context (for example, a system message or background data) into the conversation without starting a response.

### `saveMessages` vs `submitMessages`

Use `saveMessages()` when the caller can wait for the model turn to finish.

Use `submitMessages()` with Think when the caller needs a fast durable receipt, idempotent retry, and later status inspection. This is useful for webhook handlers, RPC callers, and parent Workers with strict timeout limits:

* [  JavaScript ](#tab-panel-5337)
* [  TypeScript ](#tab-panel-5338)

JavaScript

```
const submission = await this.submitMessages(  [    {      id: crypto.randomUUID(),      role: "user",      parts: [        { type: "text", text: `Webhook event: ${JSON.stringify(payload)}` },      ],    },  ],  { idempotencyKey: payload.id },);
return Response.json({  submissionId: submission.submissionId,  status: submission.status,  accepted: submission.accepted,});
```

TypeScript

```
const submission = await this.submitMessages(  [    {      id: crypto.randomUUID(),      role: "user",      parts: [        { type: "text", text: `Webhook event: ${JSON.stringify(payload)}` },      ],    },  ],  { idempotencyKey: payload.id },);
return Response.json({  submissionId: submission.submissionId,  status: submission.status,  accepted: submission.accepted,});
```

`submitMessages()` stores pending work first and appends the messages to the conversation Session only when the submission starts executing. It accepts serializable `UIMessage[]` values, not the function form supported by `saveMessages((messages) => ...)`.

Use [startFiber()](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/#startfiber) outside Think when the durable unit is a surrounding application job, such as accepting a webhook once, restoring provider state, posting a visible reply, and recording recovery policy. `submitMessages()` owns Think's conversation admission; managed fibers own external side effects around that turn.

For the full Think API, refer to [submitMessages()](https://developers.cloudflare.com/agents/harnesses/think/programmatic-submissions/#submitmessages).

### When to use `saveMessages` vs `onChatResponse`

**Use `saveMessages` when you control the trigger** — schedule callbacks, webhooks, email handlers, or any method where you decide when to inject a message.

**Use `onChatResponse` when you need to react to responses you did not trigger** — user-initiated messages, auto-continuations after tool approvals, or any turn that the framework ran on your behalf.

## `waitUntilStable`

Always call `waitUntilStable()` before reading `this.messages` or calling `saveMessages` from schedule callbacks, webhooks, email handlers, or other non-chat entry points.

`waitUntilStable()` waits until the conversation is fully stable:

* No active LLM stream in progress
* No pending client-tool interactions (tool results or approvals the user has not yet provided)
* No queued continuation turns

It returns `true` when stable, or `false` if the timeout expires before a pending interaction resolves. If nothing is pending, it returns immediately.

* [  JavaScript ](#tab-panel-5335)
* [  TypeScript ](#tab-panel-5336)

JavaScript

```
const stable = await this.waitUntilStable({ timeout: 30_000 });if (!stable) {  // The conversation is blocked on a user interaction or an in-flight  // stream that did not complete within 30 seconds.  console.warn("Conversation not stable, skipping server-driven message");  return;}// Safe to read this.messages and call saveMessages.
```

TypeScript

```
const stable = await this.waitUntilStable({ timeout: 30_000 });if (!stable) {  // The conversation is blocked on a user interaction or an in-flight  // stream that did not complete within 30 seconds.  console.warn("Conversation not stable, skipping server-driven message");  return;}// Safe to read this.messages and call saveMessages.
```

Without this guard, you risk reading stale messages or overlapping with an in-flight stream.

## Trigger patterns

### Cron schedule

A daily digest agent that summarizes activity every morning. Cron schedules are idempotent by default, so calling `schedule()` in `onStart` is safe — it does not create duplicates across Durable Object restarts.

* [  JavaScript ](#tab-panel-5343)
* [  TypeScript ](#tab-panel-5344)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";
export class DigestAgent extends AIChatAgent {  async onChatMessage() {    // ... your LLM call  }
  async onStart() {    await this.schedule("0 9 * * *", "dailyDigest");  }
  async dailyDigest() {    const stable = await this.waitUntilStable({ timeout: 30_000 });    if (!stable) {      console.warn("Conversation not stable, skipping daily digest");      return;    }
    await this.saveMessages((messages) => [      ...messages,      {        id: crypto.randomUUID(),        role: "user",        parts: [          {            type: "text",            text: "Summarize what happened since your last digest.",          },        ],        createdAt: new Date(),      },    ]);    // At this point the LLM has responded and the message is persisted.  }}
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";
export class DigestAgent extends AIChatAgent {  async onChatMessage() {    // ... your LLM call  }
  async onStart() {    await this.schedule("0 9 * * *", "dailyDigest");  }
  async dailyDigest() {    const stable = await this.waitUntilStable({ timeout: 30_000 });    if (!stable) {      console.warn("Conversation not stable, skipping daily digest");      return;    }
    await this.saveMessages((messages) => [      ...messages,      {        id: crypto.randomUUID(),        role: "user",        parts: [          {            type: "text",            text: "Summarize what happened since your last digest.",          },        ],        createdAt: new Date(),      },    ]);    // At this point the LLM has responded and the message is persisted.  }}
```

The function form of `saveMessages` — `saveMessages((messages) => [...])` — reads the latest persisted messages at execution time. This avoids stale baselines when multiple calls queue up (for example, rapid webhook arrivals). Refer to [Schedule tasks](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) for more on `schedule()` and cron syntax.

### Processing a queue

When you control the trigger, a simple loop is the clearest pattern:

TypeScript

```
async processQueue() {  for (const task of this.taskQueue) {    const stable = await this.waitUntilStable({ timeout: 30_000 });    if (!stable) {      console.warn("Conversation not stable, stopping queue processing");      break;    }
    await this.saveMessages((messages) => [      ...messages,      {        id: crypto.randomUUID(),        role: "user",        parts: [{ type: "text", text: task }],        createdAt: new Date(),      },    ]);    // LLM has responded. this.messages is updated. Next iteration.  }  this.taskQueue = [];}
```

No special hooks needed — `saveMessages` returns after the full turn completes.

### Email-triggered

TypeScript

```
async onEmail(email: AgentEmail) {  const stable = await this.waitUntilStable({ timeout: 30_000 });  if (!stable) {    console.warn("Conversation not stable, cannot process email");    return;  }
  const subject = email.headers.get("subject") ?? "(no subject)";  const body = await new Response(email.raw).text();
  await this.saveMessages((messages) => [    ...messages,    {      id: crypto.randomUUID(),      role: "user",      parts: [        {          type: "text",          text: `Email from ${email.from}: ${subject}\n\n${body}`,        },      ],      createdAt: new Date(),    },  ]);}
```

### Webhook-triggered

TypeScript

```
async onRequest(request: Request): Promise<Response> {  const url = new URL(request.url);
  if (url.pathname.endsWith("/webhook") && request.method === "POST") {    const stable = await this.waitUntilStable({ timeout: 30_000 });    if (!stable) {      return new Response("Agent is busy", { status: 503 });    }
    const payload = await request.json();    try {      await this.saveMessages((messages) => [        ...messages,        {          id: crypto.randomUUID(),          role: "user",          parts: [            {              type: "text",              text: `Webhook event: ${JSON.stringify(payload)}`,            },          ],          createdAt: new Date(),        },      ]);      return new Response("ok");    } catch (error) {      console.error("Failed to process webhook:", error);      return new Response("Internal error", { status: 500 });    }  }
  return super.onRequest(request);}
```

If the webhook provider expects a quick response, use `submitMessages()` instead. This gives the provider a durable acknowledgement and lets it safely retry with the same idempotency key:

TypeScript

```
async onRequest(request: Request): Promise<Response> {  if (request.method !== "POST") return super.onRequest(request);
  const payload = await request.json<{ id: string }>();  const submission = await this.submitMessages(    [      {        id: crypto.randomUUID(),        role: "user",        parts: [          { type: "text", text: `Webhook event: ${JSON.stringify(payload)}` },        ],      },    ],    { idempotencyKey: payload.id },  );
  return Response.json({    submissionId: submission.submissionId,    accepted: submission.accepted,    status: submission.status,  });}
```

### Injecting context without triggering a response

Use `persistMessages` to add messages that the LLM will see on its next turn, without starting a turn now:

TypeScript

```
async addBackgroundContext(data: string) {  const stable = await this.waitUntilStable({ timeout: 30_000 });  if (!stable) return;
  await this.persistMessages([    ...this.messages,    {      id: crypto.randomUUID(),      role: "user",      parts: [{ type: "text", text: `[Background context]: ${data}` }],      createdAt: new Date(),    },  ]);  // Message is stored and broadcast to clients, but no LLM call happens.}
```

## Reacting to responses you did not initiate

`onChatResponse` fires after **every** completed turn — user-initiated messages, `saveMessages` calls, and auto-continuations. Use it when you need to observe or react to responses regardless of how they were triggered.

### Broadcasting state

* [  JavaScript ](#tab-panel-5339)
* [  TypeScript ](#tab-panel-5340)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    // ... your LLM call  }
  async onChatResponse(result) {    if (result.status === "completed") {      this.broadcast(JSON.stringify({ streaming: false }));    }  }}
```

TypeScript

```
import { AIChatAgent, type ChatResponseResult } from "@cloudflare/ai-chat";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    // ... your LLM call  }
  protected async onChatResponse(result: ChatResponseResult) {    if (result.status === "completed") {      this.broadcast(JSON.stringify({ streaming: false }));    }  }}
```

### Analytics

TypeScript

```
protected async onChatResponse(result: ChatResponseResult) {  try {    await fetch("https://analytics.example.com/event", {      method: "POST",      body: JSON.stringify({        requestId: result.requestId,        status: result.status,        continuation: result.continuation,      }),    });  } catch (error) {    console.error("Analytics reporting failed:", error);  }}
```

### Chained reasoning

An agent can inspect its own response and decide whether to continue. This works for user-initiated messages too — you cannot predict what the user will ask, but you can react to what the agent said.

TypeScript

```
protected async onChatResponse(result: ChatResponseResult) {  if (result.status !== "completed") return;
  const lastText = result.message.parts    .filter((p) => p.type === "text")    .map((p) => p.text)    .join("");
  if (lastText.includes("[NEEDS_MORE_RESEARCH]")) {    await this.saveMessages((messages) => [      ...messages,      {        id: crypto.randomUUID(),        role: "user",        parts: [{ type: "text", text: "Continue your research." }],        createdAt: new Date(),      },    ]);  }}
```

When `saveMessages` is called from inside `onChatResponse`, the inner turn runs to completion and `saveMessages` returns. After the current `onChatResponse` call returns, the framework fires `onChatResponse` again for the inner response. This continues until no more work is queued. The framework never nests `onChatResponse` calls — results are drained sequentially.

### Reactive queue processing

When queue items can be added by external events (user messages, webhooks) at any time, `onChatResponse` lets you drain the queue after every response regardless of who triggered it:

TypeScript

```
protected async onChatResponse(result: ChatResponseResult) {  if (result.status === "completed" && this.taskQueue.length > 0) {    const next = this.taskQueue.shift()!;    await this.saveMessages((messages) => [      ...messages,      {        id: crypto.randomUUID(),        role: "user",        parts: [{ type: "text", text: next }],        createdAt: new Date(),      },    ]);  }}
```

### `ChatResponseResult` fields

| Field        | Type                   | Description                           |                    |
| ------------ | ---------------------- | ------------------------------------- | ------------------ |
| message      | UIMessage              | The finalized assistant message       |                    |
| requestId    | string                 | Unique ID for this turn               |                    |
| continuation | boolean                | true if this was an auto-continuation |                    |
| status       | "completed" \| "error" | "aborted"                             | How the turn ended |
| error        | string \| undefined    | Error details when status is "error"  |                    |

## Client-side: detecting server-initiated streams

When the server triggers a stream via `saveMessages`, the AI SDK's `status` stays `"ready"` because the client did not initiate the request. The `useAgentChat` hook provides two additional flags to handle this:

| Flag              | What it tracks                                                                                    |
| ----------------- | ------------------------------------------------------------------------------------------------- |
| status            | AI SDK lifecycle: "submitted", "streaming", "ready", "error" — only for client-initiated requests |
| isServerStreaming | true when a server-initiated stream is active                                                     |
| isStreaming       | true when either client or server streaming is active — use this for a universal indicator        |

Use `isStreaming` for most UI concerns (disabling the send button, showing a loading indicator). Use `isServerStreaming` only when you need to distinguish between user-initiated and server-initiated streams (for example, to show a different indicator like "Agent is working in the background...").

```
import { useAgent } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {  const agent = useAgent({ agent: "ChatAgent" });  const { messages, sendMessage, isStreaming, isServerStreaming } =    useAgentChat({ agent });
  return (    <div>      {messages.map((m) => (        <div key={m.id}>{/* render message */}</div>      ))}
      {isServerStreaming && <div>Agent is working in the background...</div>}      {!isServerStreaming && isStreaming && <div>Agent is responding...</div>}
      <form        onSubmit={(e) => {          e.preventDefault();          const input = e.currentTarget.elements.namedItem(            "input",          ) as HTMLInputElement;          sendMessage({ text: input.value });          input.value = "";        }}      >        <input name="input" placeholder="Type a message..." />        <button type="submit" disabled={isStreaming}>          Send        </button>      </form>    </div>  );}
```

When a server-driven response arrives while the user is idle, connected clients see the new messages appear in real time. The `isStreaming` flag transitions from `false` to `true` to `false` as the stream runs, so UI elements like the send button automatically disable and re-enable.

## Interaction with `messageConcurrency`

The `messageConcurrency` setting on `AIChatAgent` controls how overlapping user submissions behave (`"queue"`, `"latest"`, `"merge"`, `"drop"`, `"debounce"`). This setting only applies to `sendMessage()` — user-initiated messages from the client.

`saveMessages()` always uses serialized (queued) behavior regardless of the `messageConcurrency` setting. This means server-driven messages never get dropped, merged, or debounced — they always queue up and execute in order.

## Combining with other Agent primitives

| Primitive        | How to combine                                                                                              |
| ---------------- | ----------------------------------------------------------------------------------------------------------- |
| schedule()       | Schedule a callback that calls saveMessages — see the cron example above                                    |
| queue()          | Queue a method that calls saveMessages for deferred processing                                              |
| startFiber()     | Durably accept and inspect application-owned work around a message turn                                     |
| runWorkflow()    | Start a Workflow; use AgentWorkflow.agent RPC to call a method that triggers saveMessages or submitMessages |
| onEmail()        | Convert email content to a chat message and call saveMessages                                               |
| onRequest()      | Handle webhooks and call saveMessages or submitMessages                                                     |
| this.broadcast() | Broadcast custom state from onChatResponse                                                                  |

## Cancelling a server-driven turn

Pass an `AbortSignal` when the same Durable Object starts and controls the turn:

* [  JavaScript ](#tab-panel-5341)
* [  TypeScript ](#tab-panel-5342)

JavaScript

```
const controller = new AbortController();const result = await this.saveMessages(  [    {      id: crypto.randomUUID(),      role: "user",      parts: [{ type: "text", text: "Run the long analysis." }],    },  ],  { signal: controller.signal },);
if (result.status === "aborted") {  // Partial chunks already streamed are persisted.}
```

TypeScript

```
const controller = new AbortController();const result = await this.saveMessages(  [    {      id: crypto.randomUUID(),      role: "user",      parts: [{ type: "text", text: "Run the long analysis." }],    },  ],  { signal: controller.signal },);
if (result.status === "aborted") {  // Partial chunks already streamed are persisted.}
```

`continueLastTurn()` accepts the same `options.signal` argument. `AbortSignal` objects cannot cross Durable Object RPC boundaries, and the signal is in memory only. If the Durable Object hibernates mid-turn and chat recovery is enabled, the recovered turn usually continues without the original signal; for pre-stream interruptions, recovery can instead retry the latest unanswered user message automatically. An abort fired after restart has no effect on the recovered turn.

Use `cancelSubmission(submissionId)` for durable cancellation when work was accepted with `submitMessages()` or when cancellation must cross Worker and Durable Object RPC boundaries.

Use `cancelFiber(fiberId)` when the durable unit was accepted with `startFiber()` and the cancellation should apply to the surrounding application job rather than a Think turn.

## Important notes

* **`saveMessages` is awaitable.** After it returns, the LLM has responded and the message is persisted. Use this when you control the trigger.
* **Use the function form of `saveMessages`.** `saveMessages((messages) => [...messages, newMsg])` reads the latest persisted messages at execution time, avoiding stale baselines when multiple calls queue up.
* **`persistMessages` does not trigger a response.** Use it to inject context or system messages silently.
* **`onChatResponse` is for reacting to turns you did not initiate.** Use it for user-initiated messages, auto-continuations, or any turn where you did not call `saveMessages` yourself.
* **`onChatResponse` does not nest.** When `saveMessages` is called from inside `onChatResponse`, the inner turn completes and `onChatResponse` fires again sequentially — not recursively.
* **Messages are persisted before `onChatResponse` fires.** If the Durable Object evicts during the hook, the conversation is safe in SQLite — only the hook callback is lost.
* **`waitUntilStable()` before injecting.** Always call this from schedule callbacks, webhooks, or other non-chat entry points to avoid overlapping with an in-flight stream or pending tool interaction.
* **The client sees the completed response before `onChatResponse` runs.** The server-side hook does not delay the client.
* **`messageConcurrency` does not affect `saveMessages`.** Server-driven messages always queue and execute in order.

## Next steps

[ Chat agents ](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/) Full API reference for AIChatAgent, saveMessages, persistMessages, and onChatResponse. 

[ Schedule tasks ](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) Delayed, cron, and interval scheduling for agent callbacks. 

[ Webhooks ](https://developers.cloudflare.com/agents/communication-channels/webhooks/) Receive webhook events and route them to agent instances. 

[ Email routing ](https://developers.cloudflare.com/agents/communication-channels/email/) Handle inbound emails in your agent.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/communication-channels/chat/autonomous-responses/#page","headline":"Autonomous responses · Cloudflare Agents docs","description":"Send server-initiated messages and trigger LLM responses from Cloudflare Agents without user action.","url":"https://developers.cloudflare.com/agents/communication-channels/chat/autonomous-responses/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/communication-channels/","name":"Communication channels"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/communication-channels/chat/","name":"Chat"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/communication-channels/chat/autonomous-responses/","name":"Autonomous responses"}}]}
```

---

---
title: Chat agents
description: Build AI chat interfaces with AIChatAgent and useAgentChat, including message persistence, streaming, and tool support.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Chat agents

Build AI-powered chat interfaces with `AIChatAgent` and `useAgentChat`. Messages are automatically persisted to SQLite, streams resume on disconnect, and tool calls work across server and client.

## Overview

The `@cloudflare/ai-chat` package provides two primary APIs:

| Export       | Import                    | Purpose                                                        |
| ------------ | ------------------------- | -------------------------------------------------------------- |
| AIChatAgent  | @cloudflare/ai-chat       | Server-side agent class with message persistence and streaming |
| useAgentChat | @cloudflare/ai-chat/react | React hook for building chat UIs                               |

Advanced helpers are also available from `@cloudflare/ai-chat/react`, `@cloudflare/ai-chat/types`, and `agents/chat`; see [Exports](#exports) for the full package surface.

Built on the [AI SDK ↗](https://ai-sdk.dev) and Cloudflare Durable Objects, you get:

* **Automatic message persistence** — conversations stored in SQLite, survive restarts
* **Resumable streaming** — disconnected clients resume mid-stream without data loss
* **Real-time sync** — messages broadcast to all connected clients via WebSocket
* **Tool support** — server-side, client-side, and human-in-the-loop tool patterns
* **Data parts** — attach typed JSON (citations, progress, usage) to messages alongside text
* **Row size protection** — automatic compaction when messages approach SQLite limits

## Quick start

### Install

Terminal window

```
npm install @cloudflare/ai-chat agents ai workers-ai-provider
```

### Server

* [  JavaScript ](#tab-panel-5347)
* [  TypeScript ](#tab-panel-5348)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { createWorkersAI } from "workers-ai-provider";import { streamText, convertToModelMessages } from "ai";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    // Use any provider such as workers-ai-provider, openai, anthropic, google, etc.    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      messages: await convertToModelMessages(this.messages),    });
    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { createWorkersAI } from "workers-ai-provider";import { streamText, convertToModelMessages } from "ai";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    // Use any provider such as workers-ai-provider, openai, anthropic, google, etc.    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      messages: await convertToModelMessages(this.messages),    });
    return result.toUIMessageStreamResponse();  }}
```

### Client

* [  JavaScript ](#tab-panel-5373)
* [  TypeScript ](#tab-panel-5374)

JavaScript

```
import { useAgent } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {  const agent = useAgent({ agent: "ChatAgent" });  const { messages, sendMessage, status } = useAgentChat({ agent });
  return (    <div>      {messages.map((msg) => (        <div key={msg.id}>          <strong>{msg.role}:</strong>          {msg.parts.map((part, i) =>            part.type === "text" ? <span key={i}>{part.text}</span> : null,          )}        </div>      ))}
      <form        onSubmit={(e) => {          e.preventDefault();          const input = e.currentTarget.elements.namedItem("input");          sendMessage({ text: input.value });          input.value = "";        }}      >        <input name="input" placeholder="Type a message..." />        <button type="submit" disabled={status !== "ready"}>          Send        </button>      </form>    </div>  );}
```

TypeScript

```
import { useAgent } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {  const agent = useAgent({ agent: "ChatAgent" });  const { messages, sendMessage, status } = useAgentChat({ agent });
  return (    <div>      {messages.map((msg) => (        <div key={msg.id}>          <strong>{msg.role}:</strong>          {msg.parts.map((part, i) =>            part.type === "text" ? <span key={i}>{part.text}</span> : null,          )}        </div>      ))}
      <form        onSubmit={(e) => {          e.preventDefault();          const input = e.currentTarget.elements.namedItem(            "input",          ) as HTMLInputElement;          sendMessage({ text: input.value });          input.value = "";        }}      >        <input name="input" placeholder="Type a message..." />        <button type="submit" disabled={status !== "ready"}>          Send        </button>      </form>    </div>  );}
```

### Wrangler configuration

JSONC

```
// wrangler.jsonc{  "ai": { "binding": "AI" },  "durable_objects": {    "bindings": [{ "name": "ChatAgent", "class_name": "ChatAgent" }],  },  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ChatAgent"] }],}
```

The `new_sqlite_classes` migration is required — `AIChatAgent` uses SQLite for message persistence and stream chunk buffering.

## How it works

sequenceDiagram
    participant Client as Client (useAgentChat)
    participant Agent as AIChatAgent
    participant DB as SQLite

    Client->>Agent: CF_AGENT_USE_CHAT_REQUEST (WebSocket)
    Agent->>DB: Persist messages
    Agent->>Agent: onChatMessage()
    loop Streaming response
        Agent-->>Client: CF_AGENT_USE_CHAT_RESPONSE (chunks)
        Agent->>DB: Buffer chunks
    end
    Agent->>DB: Persist final message
    Agent-->>Client: CF_AGENT_CHAT_MESSAGES (broadcast to all clients)

1. The client sends a message via WebSocket
2. `AIChatAgent` persists messages to SQLite and calls your `onChatMessage` method
3. Your method returns a streaming `Response` (typically from `streamText`)
4. Chunks stream back over WebSocket in real-time
5. When the stream completes, the final message is persisted and broadcast to all connections

## Server API

### `AIChatAgent`

Extends `Agent` from the `agents` package. Manages conversation state, persistence, and streaming.

* [  JavaScript ](#tab-panel-5353)
* [  TypeScript ](#tab-panel-5354)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";
export class ChatAgent extends AIChatAgent {  // Access current messages  // this.messages: UIMessage[]
  // Limit stored messages (optional)  maxPersistedMessages = 200;
  async onChatMessage(onFinish, options) {    // onFinish: callback for streamText (cleanup is automatic)    // options.abortSignal: cancel signal    // options.body: custom data from client    // options.continuation: true for continuation turns    // Return a Response (streaming or plain text)  }}
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";
export class ChatAgent extends AIChatAgent {  // Access current messages  // this.messages: UIMessage[]
  // Limit stored messages (optional)  maxPersistedMessages = 200;
  async onChatMessage(onFinish, options?) {    // onFinish: callback for streamText (cleanup is automatic)    // options.abortSignal: cancel signal    // options.body: custom data from client    // options.continuation: true for continuation turns    // Return a Response (streaming or plain text)  }}
```

### `onChatMessage`

This is the main method you override. It receives the conversation context and should return a `Response`.

**Streaming response** (most common):

* [  JavaScript ](#tab-panel-5351)
* [  TypeScript ](#tab-panel-5352)

JavaScript

```
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      system: "You are a helpful assistant.",      messages: await convertToModelMessages(this.messages),    });
    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      system: "You are a helpful assistant.",      messages: await convertToModelMessages(this.messages),    });
    return result.toUIMessageStreamResponse();  }}
```

**Plain text response**:

TypeScript

```
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    return new Response("Hello! I am a simple agent.", {      headers: { "Content-Type": "text/plain" },    });  }}
```

**Accessing custom body data and request ID**:

TypeScript

```
export class ChatAgent extends AIChatAgent {  async onChatMessage(_onFinish, options) {    const { timezone, userId } = options?.body ?? {};    // Use these values in your LLM call or business logic
    // options.requestId — unique identifier for this chat request,    // useful for logging and correlating events    console.log("Request ID:", options?.requestId);
    if (options?.continuation) {      // This turn continues a previous assistant message after a tool result,      // continueLastTurn(), or recovery.    }  }}
```

`options.continuation` is `true` for automatic continuations after tool results or approvals, calls to `continueLastTurn()`, and recovered turns. Use it to choose a different model, adjust your system prompt, or skip expensive context assembly for continuation turns.

### `this.messages`

The current conversation history, loaded from SQLite. This is an array of `UIMessage` objects from the AI SDK. Messages are automatically persisted after each interaction.

### `maxPersistedMessages`

Cap the number of messages stored in SQLite. When the limit is exceeded, the oldest messages are deleted. This controls storage only — it does not affect what is sent to the LLM.

* [  JavaScript ](#tab-panel-5345)
* [  TypeScript ](#tab-panel-5346)

JavaScript

```
export class ChatAgent extends AIChatAgent {  maxPersistedMessages = 200;}
```

TypeScript

```
export class ChatAgent extends AIChatAgent {  maxPersistedMessages = 200;}
```

To control what is sent to the model, use the AI SDK's `pruneMessages()`:

* [  JavaScript ](#tab-panel-5361)
* [  TypeScript ](#tab-panel-5362)

JavaScript

```
import { streamText, convertToModelMessages, pruneMessages } from "ai";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      messages: pruneMessages({        messages: await convertToModelMessages(this.messages),        reasoning: "before-last-message",        toolCalls: "before-last-2-messages",      }),    });
    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
import { streamText, convertToModelMessages, pruneMessages } from "ai";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      messages: pruneMessages({        messages: await convertToModelMessages(this.messages),        reasoning: "before-last-message",        toolCalls: "before-last-2-messages",      }),    });
    return result.toUIMessageStreamResponse();  }}
```

### `waitForMcpConnections`

Controls whether `AIChatAgent` waits for MCP server connections to settle before calling `onChatMessage`. This ensures `this.mcp.getAITools()` returns the full set of tools, especially after Durable Object hibernation when connections are being restored in the background.

| Value                | Behavior                                      |
| -------------------- | --------------------------------------------- |
| { timeout: 10\_000 } | Wait up to 10 seconds (default)               |
| { timeout: N }       | Wait up to N milliseconds                     |
| true                 | Wait indefinitely until all connections ready |
| false                | Do not wait (old behavior before 0.2.0)       |

* [  JavaScript ](#tab-panel-5357)
* [  TypeScript ](#tab-panel-5358)

JavaScript

```
export class ChatAgent extends AIChatAgent {  // Default — waits up to 10 seconds  // waitForMcpConnections = { timeout: 10_000 };
  // Wait forever  waitForMcpConnections = true;
  // Disable waiting  waitForMcpConnections = false;}
```

TypeScript

```
export class ChatAgent extends AIChatAgent {  // Default — waits up to 10 seconds  // waitForMcpConnections = { timeout: 10_000 };
  // Wait forever  waitForMcpConnections = true;
  // Disable waiting  waitForMcpConnections = false;}
```

For lower-level control, call `this.mcp.waitForConnections()` directly inside your `onChatMessage` instead.

### `messageConcurrency`

Controls how overlapping user submissions behave when a chat turn is already active or queued.

* [  JavaScript ](#tab-panel-5349)
* [  TypeScript ](#tab-panel-5350)

JavaScript

```
export class ChatAgent extends AIChatAgent {  messageConcurrency = "queue";}
```

TypeScript

```
export class ChatAgent extends AIChatAgent {  messageConcurrency = "queue";}
```

| Strategy                                      | Behavior                                                                                                                            |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| "queue" (default)                             | Queue every submission and process in order                                                                                         |
| "latest"                                      | Keep only the latest overlapping submission; superseded submissions still persist their user messages but do not start a model turn |
| "merge"                                       | Queue overlapping submissions, then collapse their trailing user messages into one combined turn before the latest queued turn runs |
| "drop"                                        | Ignore overlapping submissions entirely. Messages are not persisted.                                                                |
| { strategy: "debounce", debounceMs?: number } | Trailing-edge latest with a quiet window (default 750ms)                                                                            |

This setting only applies to `sendMessage()` submissions. Regenerations, tool continuations, approvals, clears, and programmatic `saveMessages()` calls keep their existing serialized behavior.

### `persistMessages` and `saveMessages`

`persistMessages` stores messages in SQLite and broadcasts the update to all connected clients, but does **not** trigger a model turn. Use it when you want to inject messages into the conversation without starting a new response.

`saveMessages` persists messages **and** triggers `onChatMessage()` for a new response. It waits for any active chat turn to finish before starting, so scheduled or programmatic messages never overlap an in-flight stream.

* [  JavaScript ](#tab-panel-5355)
* [  TypeScript ](#tab-panel-5356)

JavaScript

```
// Store messages without triggering a responseawait this.persistMessages(messages);
// Store messages AND trigger onChatMessageconst { requestId, status } = await this.saveMessages(messages);
```

TypeScript

```
// Store messages without triggering a responseawait this.persistMessages(messages);
// Store messages AND trigger onChatMessageconst { requestId, status } = await this.saveMessages(messages);
```

`saveMessages` accepts either an array of messages or a function that derives the next message list from the latest persisted `this.messages`. Use the function form to avoid stale baselines when multiple calls queue up:

* [  JavaScript ](#tab-panel-5359)
* [  TypeScript ](#tab-panel-5360)

JavaScript

```
await this.saveMessages((messages) => [  ...messages,  {    id: crypto.randomUUID(),    role: "user",    parts: [{ type: "text", text: "Summarize the latest data" }],    createdAt: new Date(),  },]);
```

TypeScript

```
await this.saveMessages((messages) => [  ...messages,  {    id: crypto.randomUUID(),    role: "user",    parts: [{ type: "text", text: "Summarize the latest data" }],    createdAt: new Date(),  },]);
```

`saveMessages` returns `{ requestId, status, error? }` where `status` is `"completed"` if the turn ran, `"error"` if the stream reported an error, `"skipped"` if the chat was cleared before it started, or `"aborted"` if an external `AbortSignal` cancelled it before completion. When `status` is `"error"`, `error` contains the stream error message when available.

Pass `options.signal` to cancel a programmatic turn from outside the chat agent. This is useful when a parent tool call needs to cancel a child agent turn without knowing the internally generated request ID:

* [  JavaScript ](#tab-panel-5365)
* [  TypeScript ](#tab-panel-5366)

JavaScript

```
const controller = new AbortController();
const result = await this.saveMessages(  (messages) => [...messages, syntheticUserMessage],  { signal: controller.signal },);
if (result.status === "aborted") {  // Partial chunks already streamed are persisted.}
```

TypeScript

```
const controller = new AbortController();
const result = await this.saveMessages(  (messages) => [...messages, syntheticUserMessage],  { signal: controller.signal },);
if (result.status === "aborted") {  // Partial chunks already streamed are persisted.}
```

`continueLastTurn()` accepts the same `options.signal` argument. `AbortSignal` objects cannot cross Durable Object RPC boundaries, so construct the controller inside the Durable Object that calls `saveMessages()` or `continueLastTurn()`. The signal is in memory only; if the Durable Object hibernates mid-turn and `chatRecovery` is enabled, the recovered turn runs without the original signal.

### `onChatResponse`

Called after a chat turn produces and persists an assistant message. The turn lock is released before this hook runs, so it is safe to call `saveMessages` from inside. Fires for turn paths that persist an assistant message: WebSocket chat requests, `saveMessages`, and auto-continuation. If a turn fails before producing any assistant parts, the error is surfaced through the original request instead.

* [  JavaScript ](#tab-panel-5371)
* [  TypeScript ](#tab-panel-5372)

JavaScript

```
export class ChatAgent extends AIChatAgent {  async onChatResponse(result) {    if (result.status === "completed") {      console.log("Turn completed:", result.requestId);    }    if (result.status === "error") {      console.error("Turn failed:", result.error);    }  }}
```

TypeScript

```
import type { ChatResponseResult } from "@cloudflare/ai-chat";
export class ChatAgent extends AIChatAgent {  protected async onChatResponse(result: ChatResponseResult) {    if (result.status === "completed") {      console.log("Turn completed:", result.requestId);    }    if (result.status === "error") {      console.error("Turn failed:", result.error);    }  }}
```

The `ChatResponseResult` contains:

| Field        | Type                   | Description                                                       |                    |
| ------------ | ---------------------- | ----------------------------------------------------------------- | ------------------ |
| message      | UIMessage              | The finalized assistant message from this turn                    |                    |
| requestId    | string                 | The request ID associated with this turn                          |                    |
| continuation | boolean                | Whether this turn was a continuation of a previous assistant turn |                    |
| status       | "completed" \| "error" | "aborted"                                                         | How the turn ended |
| error        | string \| undefined    | Error message when status is "error"                              |                    |

Note

Responses triggered from inside `onChatResponse` (for example, via `saveMessages`) do not fire `onChatResponse` recursively.

### `sanitizeMessageForPersistence`

Override this method to apply custom transformations to messages before they are persisted to storage. This hook runs **after** the built-in sanitization (OpenAI metadata stripping, Anthropic provider-executed tool payload truncation, empty reasoning part filtering).

* [  JavaScript ](#tab-panel-5377)
* [  TypeScript ](#tab-panel-5378)

JavaScript

```
export class ChatAgent extends AIChatAgent {  sanitizeMessageForPersistence(message) {    return {      ...message,      parts: message.parts.map((part) => {        if (          "output" in part &&          typeof part.output === "string" &&          part.output.length > 1000        ) {          return { ...part, output: "[redacted]" };        }        return part;      }),    };  }}
```

TypeScript

```
export class ChatAgent extends AIChatAgent {  protected sanitizeMessageForPersistence(message: UIMessage): UIMessage {    return {      ...message,      parts: message.parts.map((part) => {        if (          "output" in part &&          typeof part.output === "string" &&          part.output.length > 1000        ) {          return { ...part, output: "[redacted]" };        }        return part;      }),    };  }}
```

### Turn lifecycle helpers

These methods help you coordinate programmatic turns and wait for pending interactions.

#### `hasPendingInteraction()`

Returns `true` when an assistant message is waiting on a client tool result or approval.

* [  JavaScript ](#tab-panel-5363)
* [  TypeScript ](#tab-panel-5364)

JavaScript

```
if (this.hasPendingInteraction()) {  console.log("Waiting for user to approve or provide tool output");}
```

TypeScript

```
if (this.hasPendingInteraction()) {  console.log("Waiting for user to approve or provide tool output");}
```

#### `waitUntilStable()`

Waits until the conversation is fully stable — no active stream, no pending client-tool interactions, and no queued continuation turns. Returns `true` when stable, or `false` if the timeout expires before a pending interaction resolves.

* [  JavaScript ](#tab-panel-5367)
* [  TypeScript ](#tab-panel-5368)

JavaScript

```
const stable = await this.waitUntilStable({ timeout: 30_000 });if (stable) {  console.log("All turns complete, safe to proceed");}
```

TypeScript

```
const stable = await this.waitUntilStable({ timeout: 30_000 });if (stable) {  console.log("All turns complete, safe to proceed");}
```

This is especially useful with `saveMessages` for server-driven flows:

* [  JavaScript ](#tab-panel-5369)
* [  TypeScript ](#tab-panel-5370)

JavaScript

```
await this.saveMessages((messages) => [...messages, syntheticUserMessage]);await this.waitUntilStable({ timeout: 60_000 });// The assistant has finished responding
```

TypeScript

```
await this.saveMessages((messages) => [...messages, syntheticUserMessage]);await this.waitUntilStable({ timeout: 60_000 });// The assistant has finished responding
```

#### `resetTurnState()`

Aborts the active turn and invalidates queued continuations. The built-in `CF_AGENT_CHAT_CLEAR` handler calls this automatically, but you can call it manually if needed.

### Lifecycle hooks

Override `onConnect` and `onClose` to add custom logic. Stream resumption and message sync are handled for you:

* [  JavaScript ](#tab-panel-5379)
* [  TypeScript ](#tab-panel-5380)

JavaScript

```
export class ChatAgent extends AIChatAgent {  async onConnect(connection, ctx) {    // Your custom logic (e.g., logging, auth checks)    console.log("Client connected:", connection.id);    // Stream resumption and message sync are handled automatically  }
  async onClose(connection, code, reason, wasClean) {    console.log("Client disconnected:", connection.id);    // Connection cleanup is handled automatically  }}
```

TypeScript

```
export class ChatAgent extends AIChatAgent {  async onConnect(connection, ctx) {    // Your custom logic (e.g., logging, auth checks)    console.log("Client connected:", connection.id);    // Stream resumption and message sync are handled automatically  }
  async onClose(connection, code, reason, wasClean) {    console.log("Client disconnected:", connection.id);    // Connection cleanup is handled automatically  }}
```

The `destroy()` method cancels any pending chat requests and cleans up stream state. It is called automatically when the Durable Object is evicted, but you can call it manually if needed.

### Request cancellation

When a user clicks "stop" in the chat UI, the client sends a `CF_AGENT_CHAT_REQUEST_CANCEL` message. The server propagates this to the `abortSignal` in `options`:

* [  JavaScript ](#tab-panel-5381)
* [  TypeScript ](#tab-panel-5382)

JavaScript

```
export class ChatAgent extends AIChatAgent {  async onChatMessage(_onFinish, options) {    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      messages: await convertToModelMessages(this.messages),      abortSignal: options?.abortSignal, // Pass through for cancellation    });
    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
export class ChatAgent extends AIChatAgent {  async onChatMessage(_onFinish, options) {    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      messages: await convertToModelMessages(this.messages),      abortSignal: options?.abortSignal, // Pass through for cancellation    });
    return result.toUIMessageStreamResponse();  }}
```

Warning

If you do not pass `abortSignal` to `streamText`, the LLM call will continue running in the background even after the user cancels. Always forward it when possible.

Subclasses can also cancel turns from inside the Durable Object:

TypeScript

```
protected abortRequest(requestId: string, reason?: unknown): voidprotected abortAllRequests(): void
```

Use `abortRequest()` when you know the request ID. Use `abortAllRequests()` for single-purpose helpers that should cancel whatever turn is currently running. Prefer `SaveMessagesOptions.signal` for programmatic turns when you can pass a signal at the call site.

### Stream recovery

Automatic stream resumption (the `resume` option on `useAgentChat`) is **client reconnect recovery** — it resumes an active stream when a client disconnects and reconnects. It does not cover Durable Object eviction: if the Worker process or Durable Object is evicted while the model call is in flight, the stream itself is gone. `chatRecovery` handles that case.

When a Durable Object is evicted mid-stream (code update, inactivity timeout, resource limit), the LLM connection is severed permanently and the in-memory streaming state is lost. `chatRecovery` wraps each chat turn in a [runFiber()](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/), providing automatic `keepAlive` during streaming and a recovery hook on restart.

* [  JavaScript ](#tab-panel-5375)
* [  TypeScript ](#tab-panel-5376)

JavaScript

```
export class ChatAgent extends AIChatAgent {  chatRecovery = true;}
```

TypeScript

```
export class ChatAgent extends AIChatAgent {  override chatRecovery = true;}
```

`AIChatAgent` defaults `chatRecovery` to `false`, so existing chat agents only get client reconnect and resumable-stream behavior unless they opt in. [Think](https://developers.cloudflare.com/agents/harnesses/think/) defaults it to `true`.

When enabled, every `onChatMessage` call runs inside a fiber. If the agent is evicted mid-stream, the fiber row survives in SQLite. On the next activation, the framework detects the interrupted fiber, reconstructs the partial response from buffered stream chunks, and calls `onChatRecovery`.

`chatRecovery` can also be set to a configuration object to bound recovery and customize the terminal experience when recovery cannot succeed:

* [  JavaScript ](#tab-panel-5389)
* [  TypeScript ](#tab-panel-5390)

JavaScript

```
export class ChatAgent extends AIChatAgent {  chatRecovery = {    maxAttempts: 10,    stableTimeoutMs: 10_000,    terminalMessage: "The assistant was interrupted and could not recover.",    // Primary stuck-turn bound. Resets on every progress-bearing attempt, so a    // turn that keeps producing content survives unbounded interruption.    noProgressTimeoutMs: 5 * 60 * 1000,    // Runaway-loop guard. Defaults to Infinity (no cap). Set a finite value to    // seal a turn that keeps emitting content but never converges.    maxRecoveryWork: 200,    // Caller policy consulted from the second recovery attempt onward. Return    // false to stop recovery. This is where you enforce a token/cost budget.    // Note: this is called as `config.shouldKeepRecovering(ctx)`, so it is not    // bound to the agent instance — track spend in your own store keyed by the    // incident.    async shouldKeepRecovering(ctx) {      return (await getSpendForTurn(ctx.recoveryRootRequestId)) < MAX_SPEND;    },    async onExhausted(ctx) {      console.warn("Chat recovery exhausted", ctx.incidentId, ctx.reason);    },  };}
```

TypeScript

```
export class ChatAgent extends AIChatAgent {  override chatRecovery = {    maxAttempts: 10,    stableTimeoutMs: 10_000,    terminalMessage: "The assistant was interrupted and could not recover.",    // Primary stuck-turn bound. Resets on every progress-bearing attempt, so a    // turn that keeps producing content survives unbounded interruption.    noProgressTimeoutMs: 5 * 60 * 1000,    // Runaway-loop guard. Defaults to Infinity (no cap). Set a finite value to    // seal a turn that keeps emitting content but never converges.    maxRecoveryWork: 200,    // Caller policy consulted from the second recovery attempt onward. Return    // false to stop recovery. This is where you enforce a token/cost budget.    // Note: this is called as `config.shouldKeepRecovering(ctx)`, so it is not    // bound to the agent instance — track spend in your own store keyed by the    // incident.    async shouldKeepRecovering(ctx) {      return (await getSpendForTurn(ctx.recoveryRootRequestId)) < MAX_SPEND;    },    async onExhausted(ctx) {      console.warn("Chat recovery exhausted", ctx.incidentId, ctx.reason);    },  };}
```

The `chatRecovery` object accepts the following configuration options:

| Field                | Default          | Description                                                                                                                                                                                                                                       |
| -------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| maxAttempts          | 10               | Attempt cap before terminal exhaustion. Resets on forward progress, so it catches a tight no-progress alarm loop, not a healthy long turn.                                                                                                        |
| stableTimeoutMs      | 10\_000          | How long a recovery attempt waits for the isolate to reach stable state before rescheduling.                                                                                                                                                      |
| terminalMessage      | generic message  | The message shown to the user when recovery is given up on.                                                                                                                                                                                       |
| noProgressTimeoutMs  | 300\_000 (5 min) | Primary stuck-turn bound: how long an incident may go without forward progress before it is sealed (no\_progress\_timeout). **Resets on every progress-bearing attempt**, so a turn that keeps producing content survives unbounded interruption. |
| maxRecoveryWork      | Infinity         | Runaway-loop guard. Maximum produced content/tool units since the incident began before a still-progressing turn is sealed. Defaults to no cap.                                                                                                   |
| shouldKeepRecovering | —                | Caller policy consulted from the second recovery attempt onward. Return false to stop recovery. Use it to enforce a token or cost budget. ctx.work is a coarse segment count, not tokens, so track real spend yourself.                           |
| onExhausted          | —                | Called once when recovery is given up on, before the terminal message is delivered. Inspect ctx.reason for why.                                                                                                                                   |

`ChatRecoveryProgressContext` (the `ctx` passed to `shouldKeepRecovering`) contains the following fields:

| Field                 | Type                  | Description                                                                                       |
| --------------------- | --------------------- | ------------------------------------------------------------------------------------------------- |
| incidentId            | string                | Stable ID for this recovery incident.                                                             |
| requestId             | string                | Request ID for the current continuation (changes per chained continuation).                       |
| recoveryRootRequestId | string                | Stable ID for the whole continuation chain — the right key for per-incident budget tracking.      |
| attempt               | number                | Attempt number for this incident (2 or greater when this hook runs).                              |
| maxAttempts           | number                | Configured attempt cap.                                                                           |
| recoveryKind          | "retry" \| "continue" | Whether recovery retries an unanswered user turn or continues a partial assistant turn.           |
| work                  | number                | Coarse, monotonic count of content/tool segments produced since the incident opened (not tokens). |
| ageMs                 | number                | Wall-clock ms since the incident's first interruption.                                            |

A progressing turn is never terminated by the framework on its own — it survives unbounded interruption (for example a dense deploy window) as long as it keeps making forward progress. Recovery is sealed only by one of these `ctx.reason` values:

* `no_progress_timeout` — no forward progress within the no-progress window (a stuck turn).
* `max_attempts_exceeded` — the attempt cap was spent on a tight no-progress alarm loop.
* `work_budget_exceeded` — the turn kept producing content but exceeded `maxRecoveryWork` (a runaway loop).
* `recovery_aborted` — your `shouldKeepRecovering` hook returned `false`.
* `stable_timeout` — recovery attempts kept timing out waiting for stable state until the budget drained (extreme churn).

Tip

A finite `maxRecoveryWork` can seal a legitimately long turn. Set a cap well above what a healthy turn produces, or use `shouldKeepRecovering` with real token or cost accounting for a precise budget.

#### Turns waiting on a human are not sealed

A turn can pause on a client interaction it cannot resolve on its own: a client-side tool call (a tool with no server `execute`, whose result the client replays), or an `approval-requested` part. Such a turn is waiting on the human, not stuck.

While the interaction is pending, the turn is exempt from every recovery budget. The no-progress window, attempt cap, `maxRecoveryWork`, and `shouldKeepRecovering` are all suspended. A user who takes minutes to answer a prompt that was interrupted by a deploy never trips a seal. Recovery parks the turn instead of failing it, and the user's eventual approval or tool result resumes it through the normal continuation path.

This exemption is client-only. A server tool whose `execute()` was killed mid-flight is a genuine orphan, so it is not exempt and recovers through transcript repair instead.

Monitor terminal exhaustion through observability:

* [  JavaScript ](#tab-panel-5383)
* [  TypeScript ](#tab-panel-5384)

JavaScript

```
import { subscribe } from "agents/observability";
const unsubscribe = subscribe("chat", (event) => {  if (event.type === "chat:recovery:exhausted") {    console.error("Chat recovery exhausted", event.payload);  }});
```

TypeScript

```
import { subscribe } from "agents/observability";
const unsubscribe = subscribe("chat", (event) => {  if (event.type === "chat:recovery:exhausted") {    console.error("Chat recovery exhausted", event.payload);  }});
```

#### `onChatRecovery`

Override to implement provider-specific recovery. The default behavior persists the partial response and schedules a continuation via `continueLastTurn()`.

* [  JavaScript ](#tab-panel-5387)
* [  TypeScript ](#tab-panel-5388)

JavaScript

```
export class ChatAgent extends AIChatAgent {  chatRecovery = true;
  async onChatRecovery(ctx) {    console.log(`Recovered ${ctx.partialText.length} chars of partial text`);
    // Default: persist partial + schedule continuation    return {};  }}
```

TypeScript

```
import type {  ChatRecoveryContext,  ChatRecoveryOptions,} from "@cloudflare/ai-chat";
export class ChatAgent extends AIChatAgent {  override chatRecovery = true;
  override async onChatRecovery(    ctx: ChatRecoveryContext,  ): Promise<ChatRecoveryOptions> {    console.log(`Recovered ${ctx.partialText.length} chars of partial text`);
    // Default: persist partial + schedule continuation    return {};  }}
```

**`ChatRecoveryContext`:**

| Field           | Type                                 | Description                                                                              |
| --------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
| incidentId      | string                               | Stable ID for this recovery incident                                                     |
| attempt         | number                               | Current attempt number for this incident, starting at 1                                  |
| maxAttempts     | number                               | Configured attempt cap before terminal exhaustion                                        |
| recoveryKind    | "retry" \| "continue"                | Whether recovery will retry an unanswered user turn or continue a partial assistant turn |
| streamId        | string                               | ID of the interrupted stream                                                             |
| requestId       | string                               | ID of the original chat request                                                          |
| partialText     | string                               | Text generated before eviction                                                           |
| partialParts    | MessagePart\[\]                      | Message parts (text, reasoning, tool calls) generated before eviction                    |
| recoveryData    | unknown \| null                      | Data from this.stash() — entirely user-controlled                                        |
| messages        | ChatMessage\[\]                      | Full conversation history                                                                |
| lastBody        | Record<string, unknown> \| undefined | The original request body                                                                |
| lastClientTools | ClientToolSchema\[\] \| undefined    | Client tool schemas from the original request                                            |
| createdAt       | number                               | Epoch milliseconds when the interrupted turn started                                     |

**`ChatRecoveryOptions`:**

| Field    | Default | Description                                       |
| -------- | ------- | ------------------------------------------------- |
| persist  | true    | Save the partial response as an assistant message |
| continue | true    | Schedule a continuation via continueLastTurn()    |

Common return values:

* `{}` — persist partial + auto-continue (default, works with providers that support assistant prefill)
* `{ continue: false }` — persist partial but do not auto-continue (handle continuation yourself)
* `{ persist: false, continue: false }` — do not persist the unsettled remainder and handle everything yourself (for example, retrieve a completed response from the provider)

Settled work is never dropped: `persist: false` only suppresses persistence of a partial that has nothing settled to lose. A partial that already carries settled tool results (completed, often non-idempotent work) is persisted regardless, so an app cannot accidentally discard completed tool calls — and never needs `{ persist: true }` just to stay safe.

When recovery happens before any stream chunks were written, there is no partial assistant message to continue. If the latest persisted message is still the unanswered user message from the interrupted turn, the framework retries that turn automatically unless `continue` is `false`.

Use `ctx.createdAt` to skip stale recoveries:

TypeScript

```
override async onChatRecovery(  ctx: ChatRecoveryContext,): Promise<ChatRecoveryOptions> {  if (Date.now() - ctx.createdAt > 2 * 60 * 1000) {    return { continue: false };  }  return {};}
```

#### `continueLastTurn`

Appends to the last assistant message by re-calling `onChatMessage` with the saved request body. The response is streamed as a continuation — appended to the existing assistant message, not a new one. No synthetic user message is created.

TypeScript

```
protected continueLastTurn(  body?: Record<string, unknown>,  options?: SaveMessagesOptions,): Promise<SaveMessagesResult>;
```

Called automatically by the default recovery path. Can also be called manually from scheduled callbacks or other entry points. The optional `body` parameter overrides the saved request body for this continuation. Pass `options.signal` to cancel the continuation while it is running.

#### Stashing recovery data

Use `this.stash()` inside `onChatMessage` to persist provider-specific data for recovery. The stash is stored in the fiber's SQLite row, separate from agent state, and available as `ctx.recoveryData` in `onChatRecovery`.

* [  JavaScript ](#tab-panel-5403)
* [  TypeScript ](#tab-panel-5404)

JavaScript

```
export class ChatAgent extends AIChatAgent {  chatRecovery = true;
  async onChatMessage(_onFinish, options) {    const result = streamText({      model: openai("gpt-5.4"),      messages: await convertToModelMessages(this.messages),      providerOptions: { openai: { store: true } },      includeRawChunks: true,      onChunk: ({ chunk }) => {        if (chunk.type === "raw") {          const raw = chunk.rawValue;
          if (raw?.type === "response.created" && raw.response?.id) {            this.stash({ responseId: raw.response.id });          }        }      },    });    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
export class ChatAgent extends AIChatAgent {  override chatRecovery = true;
  async onChatMessage(_onFinish, options) {    const result = streamText({      model: openai("gpt-5.4"),      messages: await convertToModelMessages(this.messages),      providerOptions: { openai: { store: true } },      includeRawChunks: true,      onChunk: ({ chunk }) => {        if (chunk.type === "raw") {          const raw = chunk.rawValue as {            type?: string;            response?: { id?: string };          };          if (raw?.type === "response.created" && raw.response?.id) {            this.stash({ responseId: raw.response.id });          }        }      },    });    return result.toUIMessageStreamResponse();  }}
```

#### Recovery strategies by provider

The right strategy depends on whether the provider supports assistant prefill and whether the response continues server-side after disconnection:

| Provider               | Strategy                                                   | Token cost |
| ---------------------- | ---------------------------------------------------------- | ---------- |
| Workers AI             | continueLastTurn() — model continues via assistant prefill | Low        |
| OpenAI (Responses API) | Retrieve completed response by ID — zero wasted tokens     | Zero       |
| Anthropic              | Persist partial, send a synthetic user message to continue | Medium     |

#### Recovering status on the client

While a turn is being recovered, the agent broadcasts a `cf_agent_chat_recovering` status frame so clients can show a "recovering…" indicator instead of looking frozen. It is set when a recovery continuation is scheduled and cleared on every terminal outcome, so the indicator never spins forever. Consume it through `useAgentChat`'s `isRecovering` flag (see [Return values](#return-values)). The signal is advisory and backward-compatible — clients that do not understand it ignore it.

Note

`@cloudflare/ai-chat` broadcasts the live signal but does not yet replay it on connect, so a client connecting mid-recovery is not re-told until it reconnects to an active stream. [Think](https://developers.cloudflare.com/agents/harnesses/think/) replays it on connect.

Transcript repairs — healing orphaned tool calls (preserved as errored results rather than deleted, so the record survives and the model does not silently re-run the tool) and normalizing malformed or missing tool inputs before a provider call — are emitted on the `transcript` observability channel.

For how chat recovery fits into the broader long-running agents story, refer to [Long-running agents: Recovering interrupted LLM streams](https://developers.cloudflare.com/agents/concepts/agentic-patterns/long-running-agents/#recovering-interrupted-llm-streams). For the underlying fiber API, refer to [Durable Execution](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/).

## Client API

### `useAgentChat`

React hook that connects to an `AIChatAgent` over WebSocket. Wraps the AI SDK's `useChat` with a native WebSocket transport.

* [  JavaScript ](#tab-panel-5401)
* [  TypeScript ](#tab-panel-5402)

JavaScript

```
import { useAgent } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {  const agent = useAgent({ agent: "ChatAgent" });  const {    messages,    sendMessage,    clearHistory,    addToolOutput,    addToolApprovalResponse,    setMessages,    status,    isStreaming,    isServerStreaming,    isToolContinuation,    isRecovering,  } = useAgentChat({ agent });
  // ...}
```

TypeScript

```
import { useAgent } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {  const agent = useAgent({ agent: "ChatAgent" });  const {    messages,    sendMessage,    clearHistory,    addToolOutput,    addToolApprovalResponse,    setMessages,    status,    isStreaming,    isServerStreaming,    isToolContinuation,    isRecovering,  } = useAgentChat({ agent });
  // ...}
```

### Options

| Option                      | Type                                        | Default  | Description                                                                                                                                                                |
| --------------------------- | ------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| agent                       | ReturnType<typeof useAgent>                 | Required | Agent connection from useAgent                                                                                                                                             |
| onToolCall                  | ({ toolCall, addToolOutput }) => void       | —        | Handle client-side tool execution                                                                                                                                          |
| tools                       | Record<string, AITool>                      | —        | Advanced: dynamically register client-executed tools from the browser                                                                                                      |
| autoContinueAfterToolResult | boolean                                     | true     | Auto-continue conversation after client tool results and approvals                                                                                                         |
| resume                      | boolean                                     | true     | Enable automatic stream resumption on reconnect                                                                                                                            |
| cancelOnClientAbort         | boolean                                     | false    | Cancel the server turn when generic client stream abort or cleanup occurs                                                                                                  |
| body                        | object \| () => object                      | —        | Custom data sent with every request                                                                                                                                        |
| prepareSendMessagesRequest  | (options) => { body?, headers? }            | —        | Advanced per-request customization                                                                                                                                         |
| getInitialMessages          | (options) => Promise<UIMessage\[\]> or null | —        | Custom initial message loader. Set to null to skip the HTTP fetch entirely (useful when providing messages directly)                                                       |
| syncMessagesToServer        | boolean                                     | true     | When true, setMessages pushes the transcript to the server. Set to false for hosts with server-authoritative transcript storage so setMessages updates the local view only |

### Return values

| Property                | Type                             | Description                                                                                                                                                                                                                            |
| ----------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| messages                | UIMessage\[\]                    | Current conversation messages                                                                                                                                                                                                          |
| sendMessage             | (message) => void                | Send a message                                                                                                                                                                                                                         |
| clearHistory            | () => void                       | Clear conversation (client and server)                                                                                                                                                                                                 |
| addToolOutput           | ({ toolCallId, output }) => void | Provide output for a client-side tool                                                                                                                                                                                                  |
| addToolApprovalResponse | ({ id, approved }) => void       | Approve or reject a tool requiring approval                                                                                                                                                                                            |
| setMessages             | (messages \| updater) => void    | Set messages directly (syncs to server)                                                                                                                                                                                                |
| status                  | string                           | "ready", "submitted", "streaming", or "error"                                                                                                                                                                                          |
| isStreaming             | boolean                          | true while the agent is streaming or waiting on an active client tool                                                                                                                                                                  |
| isServerStreaming       | boolean                          | true while a server-initiated stream or active client-tool phase is in progress                                                                                                                                                        |
| isToolContinuation      | boolean                          | true while an automatic continuation after a tool result or approval is running                                                                                                                                                        |
| isRecovering            | boolean                          | true while a durable turn is being recovered (interrupted and resuming). Distinct from isStreaming — a recovering turn is not producing tokens yet. Render a "recovering…" hint; most UIs treat isStreaming \|| isRecovering as "busy" |

Use `isToolContinuation` when your UI should distinguish a fresh user submit from a continuation after a tool result. For example, show a typing indicator only for `status === "submitted" && !isToolContinuation`, while keeping loading controls disabled whenever `isStreaming` is true.

## Tools

`AIChatAgent` supports three tool patterns, all using the AI SDK's `tool()` function:

| Pattern     | Where it runs                | When to use                                   |
| ----------- | ---------------------------- | --------------------------------------------- |
| Server-side | Server (automatic)           | API calls, database queries, computations     |
| Client-side | Browser (via onToolCall)     | Geolocation, clipboard, camera, local storage |
| Approval    | Server (after user approval) | Payments, deletions, external actions         |

### Server-side tools

Tools with an `execute` function run automatically on the server:

* [  JavaScript ](#tab-panel-5407)
* [  TypeScript ](#tab-panel-5408)

JavaScript

```
import { streamText, convertToModelMessages, tool, stepCountIs } from "ai";import { z } from "zod";export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      messages: await convertToModelMessages(this.messages),      tools: {        getWeather: tool({          description: "Get weather for a city",          inputSchema: z.object({ city: z.string() }),          execute: async ({ city }) => {            const data = await fetchWeather(city);            return { temperature: data.temp, condition: data.condition };          },        }),      },      stopWhen: stepCountIs(5),    });
    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
import { streamText, convertToModelMessages, tool, stepCountIs } from "ai";import { z } from "zod";export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      messages: await convertToModelMessages(this.messages),      tools: {        getWeather: tool({          description: "Get weather for a city",          inputSchema: z.object({ city: z.string() }),          execute: async ({ city }) => {            const data = await fetchWeather(city);            return { temperature: data.temp, condition: data.condition };          },        }),      },      stopWhen: stepCountIs(5),    });
    return result.toUIMessageStreamResponse();  }}
```

### Client-side tools

Define a tool on the server without `execute`, then handle it on the client with `onToolCall`. Use this for tools that need browser APIs.

**Server:**

* [  JavaScript ](#tab-panel-5385)
* [  TypeScript ](#tab-panel-5386)

JavaScript

```
tools: {  getLocation: tool({    description: "Get the user's location from the browser",    inputSchema: z.object({}),    // No execute — the client handles it  });}
```

TypeScript

```
tools: {  getLocation: tool({    description: "Get the user's location from the browser",    inputSchema: z.object({}),    // No execute — the client handles it  });}
```

**Client:**

* [  JavaScript ](#tab-panel-5397)
* [  TypeScript ](#tab-panel-5398)

JavaScript

```
const { messages, sendMessage } = useAgentChat({  agent,  onToolCall: async ({ toolCall, addToolOutput }) => {    if (toolCall.toolName === "getLocation") {      const pos = await new Promise((resolve, reject) =>        navigator.geolocation.getCurrentPosition(resolve, reject),      );      addToolOutput({        toolCallId: toolCall.toolCallId,        output: { lat: pos.coords.latitude, lng: pos.coords.longitude },      });    }  },});
```

TypeScript

```
const { messages, sendMessage } = useAgentChat({  agent,  onToolCall: async ({ toolCall, addToolOutput }) => {    if (toolCall.toolName === "getLocation") {      const pos = await new Promise((resolve, reject) =>        navigator.geolocation.getCurrentPosition(resolve, reject),      );      addToolOutput({        toolCallId: toolCall.toolCallId,        output: { lat: pos.coords.latitude, lng: pos.coords.longitude },      });    }  },});
```

When the LLM invokes `getLocation`, the stream pauses. The `onToolCall` callback fires, your code provides the output, and the conversation continues.

For SDKs or platforms where the browser decides the available tools at runtime, pass a `tools` object to `useAgentChat`. Tools with client-side `execute` functions are serialized and sent to the server automatically. On the server, `options.clientTools` and `createToolsFromClientSchemas()` remain supported for this dynamic tool pattern.

### Tool approval (human-in-the-loop)

Use `needsApproval` for tools that require user confirmation before executing.

**Server:**

TypeScript

```
tools: {  processPayment: tool({    description: "Process a payment",    inputSchema: z.object({      amount: z.coerce.number(),      recipient: z.string(),    }),    needsApproval: async ({ amount }) => amount > 100,    execute: async ({ amount, recipient }) => charge(amount, recipient),  });}
```

**Client:**

TypeScript

```
import { getToolName, isToolUIPart } from "ai";import {  getToolApproval,  getToolCallId,  getToolPartState,} from "@cloudflare/ai-chat/react";
const { messages, addToolApprovalResponse } = useAgentChat({ agent });
// Render pending approvals from message parts{  messages.map((msg) =>    msg.parts      .filter(        (part) =>          isToolUIPart(part) && getToolPartState(part) === "waiting-approval",      )      .map((part) => (        <div key={getToolCallId(part)}>          <p>Approve {getToolName(part)}?</p>          <button            onClick={() => {              const approval = getToolApproval(part);              if (!approval) return;              addToolApprovalResponse({                id: approval.id,                approved: true,              });            }}          >            Approve          </button>          <button            onClick={() => {              const approval = getToolApproval(part);              if (!approval) return;              addToolApprovalResponse({                id: approval.id,                approved: false,              });            }}          >            Reject          </button>        </div>      )),  );}
```

#### Custom denial messages with `addToolOutput`

When a user rejects a tool, `addToolApprovalResponse({ id, approved: false })` sets the tool state to `output-denied` with a generic message. To give the LLM a more specific reason for the denial, use `addToolOutput` with `state: "output-error"` instead:

* [  JavaScript ](#tab-panel-5391)
* [  TypeScript ](#tab-panel-5392)

JavaScript

```
const { addToolOutput } = useAgentChat({ agent });
// Reject with a custom error messageaddToolOutput({  toolCallId: part.toolCallId,  state: "output-error",  errorText: "User declined: insufficient budget for this quarter",});
```

TypeScript

```
const { addToolOutput } = useAgentChat({ agent });
// Reject with a custom error messageaddToolOutput({  toolCallId: part.toolCallId,  state: "output-error",  errorText: "User declined: insufficient budget for this quarter",});
```

This sends a `tool_result` to the LLM with your custom error text, so it can respond appropriately (for example, suggest an alternative or ask clarifying questions).

`addToolApprovalResponse` (with `approved: false`) auto-continues the conversation when `autoContinueAfterToolResult` is enabled (the default). `addToolOutput` with `state: "output-error"` does **not** auto-continue — call `sendMessage()` afterward if you want the LLM to respond to the error.

For more patterns, refer to [Human-in-the-loop](https://developers.cloudflare.com/agents/concepts/agentic-patterns/human-in-the-loop/).

## Custom request data

Include custom data with every chat request using the `body` option:

* [  JavaScript ](#tab-panel-5395)
* [  TypeScript ](#tab-panel-5396)

JavaScript

```
const { messages, sendMessage } = useAgentChat({  agent,  body: {    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,    userId: currentUser.id,  },});
```

TypeScript

```
const { messages, sendMessage } = useAgentChat({  agent,  body: {    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,    userId: currentUser.id,  },});
```

For dynamic values, use a function:

* [  JavaScript ](#tab-panel-5393)
* [  TypeScript ](#tab-panel-5394)

JavaScript

```
body: () => ({  token: getAuthToken(),  timestamp: Date.now(),});
```

TypeScript

```
body: () => ({  token: getAuthToken(),  timestamp: Date.now(),});
```

Access these fields on the server:

* [  JavaScript ](#tab-panel-5399)
* [  TypeScript ](#tab-panel-5400)

JavaScript

```
export class ChatAgent extends AIChatAgent {  async onChatMessage(_onFinish, options) {    const { timezone, userId } = options?.body ?? {};    // ...  }}
```

TypeScript

```
export class ChatAgent extends AIChatAgent {  async onChatMessage(_onFinish, options) {    const { timezone, userId } = options?.body ?? {};    // ...  }}
```

For advanced per-request customization (custom headers, different body per request), use `prepareSendMessagesRequest`:

* [  JavaScript ](#tab-panel-5405)
* [  TypeScript ](#tab-panel-5406)

JavaScript

```
const { messages, sendMessage } = useAgentChat({  agent,  prepareSendMessagesRequest: async ({ messages, trigger }) => ({    headers: { Authorization: `Bearer ${await getToken()}` },    body: { requestedAt: Date.now() },  }),});
```

TypeScript

```
const { messages, sendMessage } = useAgentChat({  agent,  prepareSendMessagesRequest: async ({ messages, trigger }) => ({    headers: { Authorization: `Bearer ${await getToken()}` },    body: { requestedAt: Date.now() },  }),});
```

## Data parts

Data parts let you attach typed JSON to messages alongside text — progress indicators, source citations, token usage, or any structured data your UI needs.

### Writing data parts (server)

Use `createUIMessageStream` with `writer.write()` to send data parts from the server:

* [  JavaScript ](#tab-panel-5429)
* [  TypeScript ](#tab-panel-5430)

JavaScript

```
import {  streamText,  convertToModelMessages,  createUIMessageStream,  createUIMessageStreamResponse,} from "ai";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });
    const stream = createUIMessageStream({      execute: async ({ writer }) => {        const result = streamText({          model: workersai("@cf/zai-org/glm-4.7-flash"),          messages: await convertToModelMessages(this.messages),        });
        // Merge the LLM stream        writer.merge(result.toUIMessageStream());
        // Write a data part — persisted to message.parts        writer.write({          type: "data-sources",          id: "src-1",          data: { query: "agents", status: "searching", results: [] },        });
        // Later: update the same part in-place (same type + id)        writer.write({          type: "data-sources",          id: "src-1",          data: {            query: "agents",            status: "found",            results: ["Agents SDK docs", "Durable Objects guide"],          },        });      },    });
    return createUIMessageStreamResponse({ stream });  }}
```

TypeScript

```
import {  streamText,  convertToModelMessages,  createUIMessageStream,  createUIMessageStreamResponse,} from "ai";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });
    const stream = createUIMessageStream({      execute: async ({ writer }) => {        const result = streamText({          model: workersai("@cf/zai-org/glm-4.7-flash"),          messages: await convertToModelMessages(this.messages),        });
        // Merge the LLM stream        writer.merge(result.toUIMessageStream());
        // Write a data part — persisted to message.parts        writer.write({          type: "data-sources",          id: "src-1",          data: { query: "agents", status: "searching", results: [] },        });
        // Later: update the same part in-place (same type + id)        writer.write({          type: "data-sources",          id: "src-1",          data: {            query: "agents",            status: "found",            results: ["Agents SDK docs", "Durable Objects guide"],          },        });      },    });
    return createUIMessageStreamResponse({ stream });  }}
```

### Three patterns

| Pattern            | How                                          | Persisted? | Use case                              |
| ------------------ | -------------------------------------------- | ---------- | ------------------------------------- |
| **Reconciliation** | Same type \+ id → updates in-place           | Yes        | Progressive state (searching → found) |
| **Append**         | No id, or different id → appends             | Yes        | Log entries, multiple citations       |
| **Transient**      | transient: true → not added to message.parts | No         | Ephemeral status (thinking indicator) |

Transient parts are broadcast to connected clients in real time but excluded from SQLite persistence and `message.parts`. Use the `onData` callback to consume them.

### Reading data parts (client)

Non-transient data parts appear in `message.parts`. Use the `UIMessage` generic to type them:

* [  JavaScript ](#tab-panel-5419)
* [  TypeScript ](#tab-panel-5420)

JavaScript

```
import { useAgentChat } from "@cloudflare/ai-chat/react";
const { messages } = useAgentChat({ agent });
// Typed access — no casts neededfor (const msg of messages) {  for (const part of msg.parts) {    if (part.type === "data-sources") {      console.log(part.data.results); // string[]    }  }}
```

TypeScript

```
import { useAgentChat } from "@cloudflare/ai-chat/react";import type { UIMessage } from "ai";
type ChatMessage = UIMessage<  unknown,  {    sources: { query: string; status: string; results: string[] };    usage: { model: string; inputTokens: number; outputTokens: number };  }>;
const { messages } = useAgentChat<unknown, ChatMessage>({ agent });
// Typed access — no casts neededfor (const msg of messages) {  for (const part of msg.parts) {    if (part.type === "data-sources") {      console.log(part.data.results); // string[]    }  }}
```

### Transient parts with `onData`

Transient data parts are not in `message.parts`. Use the `onData` callback instead:

* [  JavaScript ](#tab-panel-5415)
* [  TypeScript ](#tab-panel-5416)

JavaScript

```
const [thinking, setThinking] = useState(false);
const { messages } = useAgentChat({  agent,  onData(part) {    if (part.type === "data-thinking") {      setThinking(true);    }  },});
```

TypeScript

```
const [thinking, setThinking] = useState(false);
const { messages } = useAgentChat<unknown, ChatMessage>({  agent,  onData(part) {    if (part.type === "data-thinking") {      setThinking(true);    }  },});
```

On the server, write transient parts with `transient: true`:

* [  JavaScript ](#tab-panel-5409)
* [  TypeScript ](#tab-panel-5410)

JavaScript

```
writer.write({  transient: true,  type: "data-thinking",  data: { model: "glm-4.7-flash", startedAt: new Date().toISOString() },});
```

TypeScript

```
writer.write({  transient: true,  type: "data-thinking",  data: { model: "glm-4.7-flash", startedAt: new Date().toISOString() },});
```

`onData` fires on all code paths — new messages, stream resumption, and cross-tab broadcasts.

## Resumable streaming

Streams automatically resume when a client disconnects and reconnects. No configuration is needed — it works out of the box.

When streaming is active:

1. All chunks are buffered in SQLite as they are generated
2. If the client disconnects, the server continues streaming and buffering
3. When the client reconnects, it receives all buffered chunks and resumes live streaming

Generic client stream abort or cleanup stays local to the browser by default, so the server turn keeps running and can be resumed later. Calling `stop()` explicitly still cancels the server turn:

* [  JavaScript ](#tab-panel-5411)
* [  TypeScript ](#tab-panel-5412)

JavaScript

```
const { messages, stop } = useAgentChat({ agent });
return <button onClick={stop}>Stop</button>;
```

TypeScript

```
const { messages, stop } = useAgentChat({ agent });
return <button onClick={stop}>Stop</button>;
```

Set `cancelOnClientAbort: true` when your app intentionally wants the browser lifecycle to own the server lifecycle, such as request-lifetime or token-saving flows. Explicit `stop()` always cancels server work regardless of this option.

Disable with `resume: false`:

* [  JavaScript ](#tab-panel-5413)
* [  TypeScript ](#tab-panel-5414)

JavaScript

```
const { messages } = useAgentChat({ agent, resume: false });
```

TypeScript

```
const { messages } = useAgentChat({ agent, resume: false });
```

## Storage management

### Row size protection

Workers SQLite rows have a hard maximum size of 2 MB. To stay below that limit, `AIChatAgent` starts compacting a serialized message at roughly 1.8 MB, for example when a tool returns a very large output:

1. **Tool output compaction** — Large tool outputs are replaced with an LLM-friendly summary that instructs the model to suggest re-running the tool
2. **Text truncation** — If the message is still too large after tool compaction, text parts are truncated with a note

Compacted messages include `metadata.compactedToolOutputs` so clients can detect and display this gracefully.

### Controlling LLM context vs storage

Storage (`maxPersistedMessages`) and LLM context are independent:

| Concern                         | Control              | Scope       |
| ------------------------------- | -------------------- | ----------- |
| How many messages SQLite stores | maxPersistedMessages | Persistence |
| What the model sees             | pruneMessages()      | LLM context |
| Row size limits                 | Automatic compaction | Per-message |

* [  JavaScript ](#tab-panel-5425)
* [  TypeScript ](#tab-panel-5426)

JavaScript

```
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      messages: pruneMessages({        // LLM context limit        messages: await convertToModelMessages(this.messages),        reasoning: "before-last-message",        toolCalls: "before-last-2-messages",      }),    });
    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      messages: pruneMessages({        // LLM context limit        messages: await convertToModelMessages(this.messages),        reasoning: "before-last-message",        toolCalls: "before-last-2-messages",      }),    });
    return result.toUIMessageStreamResponse();  }}
```

## Using different AI providers

`AIChatAgent` works with any AI SDK-compatible provider. The server code determines which model to use — the client does not need to change it manually.

### Workers AI (Cloudflare)

* [  JavaScript ](#tab-panel-5417)
* [  TypeScript ](#tab-panel-5418)

JavaScript

```
import { createWorkersAI } from "workers-ai-provider";
const workersai = createWorkersAI({ binding: this.env.AI });const result = streamText({  model: workersai("@cf/zai-org/glm-4.7-flash"),  messages: await convertToModelMessages(this.messages),});
```

TypeScript

```
import { createWorkersAI } from "workers-ai-provider";
const workersai = createWorkersAI({ binding: this.env.AI });const result = streamText({  model: workersai("@cf/zai-org/glm-4.7-flash"),  messages: await convertToModelMessages(this.messages),});
```

### OpenAI

* [  JavaScript ](#tab-panel-5421)
* [  TypeScript ](#tab-panel-5422)

JavaScript

```
import { createOpenAI } from "@ai-sdk/openai";
const openai = createOpenAI({ apiKey: this.env.OPENAI_API_KEY });const result = streamText({  model: openai.chat("gpt-4o"),  messages: await convertToModelMessages(this.messages),});
```

TypeScript

```
import { createOpenAI } from "@ai-sdk/openai";
const openai = createOpenAI({ apiKey: this.env.OPENAI_API_KEY });const result = streamText({  model: openai.chat("gpt-4o"),  messages: await convertToModelMessages(this.messages),});
```

### Anthropic

* [  JavaScript ](#tab-panel-5423)
* [  TypeScript ](#tab-panel-5424)

JavaScript

```
import { createAnthropic } from "@ai-sdk/anthropic";
const anthropic = createAnthropic({ apiKey: this.env.ANTHROPIC_API_KEY });const result = streamText({  model: anthropic("claude-sonnet-4-20250514"),  messages: await convertToModelMessages(this.messages),});
```

TypeScript

```
import { createAnthropic } from "@ai-sdk/anthropic";
const anthropic = createAnthropic({ apiKey: this.env.ANTHROPIC_API_KEY });const result = streamText({  model: anthropic("claude-sonnet-4-20250514"),  messages: await convertToModelMessages(this.messages),});
```

## Advanced patterns

Since `onChatMessage` gives you full control over the `streamText` call, you can use any AI SDK feature directly. The patterns below all work out of the box — no special `AIChatAgent` configuration is needed.

### Dynamic model and tool control

Use [prepareStep ↗](https://ai-sdk.dev/docs/agents/loop-control) to change the model, available tools, or system prompt between steps in a multi-step agent loop:

* [  JavaScript ](#tab-panel-5433)
* [  TypeScript ](#tab-panel-5434)

JavaScript

```
import { streamText, convertToModelMessages, tool, stepCountIs } from "ai";import { z } from "zod";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const result = streamText({      model: cheapModel, // Default model for simple steps      messages: await convertToModelMessages(this.messages),      tools: {        search: searchTool,        analyze: analyzeTool,        summarize: summarizeTool,      },      stopWhen: stepCountIs(10),      prepareStep: async ({ stepNumber, messages }) => {        // Phase 1: Search (steps 0-2)        if (stepNumber <= 2) {          return {            activeTools: ["search"],            toolChoice: "required", // Force tool use          };        }
        // Phase 2: Analyze with a stronger model (steps 3-5)        if (stepNumber <= 5) {          return {            model: expensiveModel,            activeTools: ["analyze"],          };        }
        // Phase 3: Summarize        return { activeTools: ["summarize"] };      },    });
    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
import { streamText, convertToModelMessages, tool, stepCountIs } from "ai";import { z } from "zod";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const result = streamText({      model: cheapModel, // Default model for simple steps      messages: await convertToModelMessages(this.messages),      tools: {        search: searchTool,        analyze: analyzeTool,        summarize: summarizeTool,      },      stopWhen: stepCountIs(10),      prepareStep: async ({ stepNumber, messages }) => {        // Phase 1: Search (steps 0-2)        if (stepNumber <= 2) {          return {            activeTools: ["search"],            toolChoice: "required", // Force tool use          };        }
        // Phase 2: Analyze with a stronger model (steps 3-5)        if (stepNumber <= 5) {          return {            model: expensiveModel,            activeTools: ["analyze"],          };        }
        // Phase 3: Summarize        return { activeTools: ["summarize"] };      },    });
    return result.toUIMessageStreamResponse();  }}
```

`prepareStep` runs before each step and can return overrides for `model`, `activeTools`, `toolChoice`, `system`, and `messages`. Use it to:

* **Switch models** — use a cheap model for simple steps, escalate for reasoning
* **Phase tools** — restrict which tools are available at each step
* **Manage context** — prune or transform messages to stay within token limits
* **Force tool calls** — use `toolChoice: { type: "tool", toolName: "search" }` to require a specific tool

### Language model middleware

Use [wrapLanguageModel ↗](https://ai-sdk.dev/docs/ai-sdk-core/middleware) to add guardrails, RAG, caching, or logging without modifying your chat logic:

* [  JavaScript ](#tab-panel-5431)
* [  TypeScript ](#tab-panel-5432)

JavaScript

```
import { streamText, convertToModelMessages, wrapLanguageModel } from "ai";
const guardrailMiddleware = {  wrapGenerate: async ({ doGenerate }) => {    const { text, ...rest } = await doGenerate();    // Filter PII or sensitive content from the response    const cleaned = text?.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]");    return { text: cleaned, ...rest };  },};
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const model = wrapLanguageModel({      model: baseModel,      middleware: [guardrailMiddleware],    });
    const result = streamText({      model,      messages: await convertToModelMessages(this.messages),    });
    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
import { streamText, convertToModelMessages, wrapLanguageModel } from "ai";import type { LanguageModelV3Middleware } from "@ai-sdk/provider";
const guardrailMiddleware: LanguageModelV3Middleware = {  wrapGenerate: async ({ doGenerate }) => {    const { text, ...rest } = await doGenerate();    // Filter PII or sensitive content from the response    const cleaned = text?.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]");    return { text: cleaned, ...rest };  },};
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const model = wrapLanguageModel({      model: baseModel,      middleware: [guardrailMiddleware],    });
    const result = streamText({      model,      messages: await convertToModelMessages(this.messages),    });
    return result.toUIMessageStreamResponse();  }}
```

The AI SDK includes built-in middlewares:

* `extractReasoningMiddleware` — surface chain-of-thought from models like DeepSeek R1
* `defaultSettingsMiddleware` — apply default temperature, max tokens, etc.
* `simulateStreamingMiddleware` — add streaming to non-streaming models

Multiple middlewares compose in order: `middleware: [first, second]` applies as `first(second(model))`.

### Structured output

Use [generateObject ↗](https://ai-sdk.dev/docs/ai-sdk-core/generating-structured-data) inside tools for structured data extraction:

* [  JavaScript ](#tab-panel-5435)
* [  TypeScript ](#tab-panel-5436)

JavaScript

```
import {  streamText,  generateObject,  convertToModelMessages,  tool,  stepCountIs,} from "ai";import { z } from "zod";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const result = streamText({      model: myModel,      messages: await convertToModelMessages(this.messages),      tools: {        extractContactInfo: tool({          description:            "Extract structured contact information from the conversation",          inputSchema: z.object({            text: z.string().describe("The text to extract contact info from"),          }),          execute: async ({ text }) => {            const { object } = await generateObject({              model: myModel,              schema: z.object({                name: z.string(),                email: z.string().email(),                phone: z.string().optional(),              }),              prompt: `Extract contact information from: ${text}`,            });            return object;          },        }),      },      stopWhen: stepCountIs(5),    });
    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
import {  streamText,  generateObject,  convertToModelMessages,  tool,  stepCountIs,} from "ai";import { z } from "zod";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const result = streamText({      model: myModel,      messages: await convertToModelMessages(this.messages),      tools: {        extractContactInfo: tool({          description:            "Extract structured contact information from the conversation",          inputSchema: z.object({            text: z.string().describe("The text to extract contact info from"),          }),          execute: async ({ text }) => {            const { object } = await generateObject({              model: myModel,              schema: z.object({                name: z.string(),                email: z.string().email(),                phone: z.string().optional(),              }),              prompt: `Extract contact information from: ${text}`,            });            return object;          },        }),      },      stopWhen: stepCountIs(5),    });
    return result.toUIMessageStreamResponse();  }}
```

### In-process subagent delegation

Note

This section covers **in-process** subagents using the AI SDK's `ToolLoopAgent`. For **Durable Object sub-agents** with their own isolated storage and typed RPC, refer to [Sub-agents](https://developers.cloudflare.com/agents/runtime/execution/sub-agents/). To run Think or `AIChatAgent` sub-agents as retained, streaming tools, refer to [Agents as tools](https://developers.cloudflare.com/agents/runtime/execution/agent-tools/).

Tools can delegate work to focused sub-calls with their own context. Use [ToolLoopAgent ↗](https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent) to define a reusable agent, then call it from a tool's `execute`:

* [  JavaScript ](#tab-panel-5437)
* [  TypeScript ](#tab-panel-5438)

JavaScript

```
import {  ToolLoopAgent,  streamText,  convertToModelMessages,  tool,  stepCountIs,} from "ai";import { z } from "zod";
// Define a reusable research agent with its own tools and instructionsconst researchAgent = new ToolLoopAgent({  model: researchModel,  instructions: "You are a research assistant. Be thorough and cite sources.",  tools: { webSearch: webSearchTool },  stopWhen: stepCountIs(10),});
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const result = streamText({      model: orchestratorModel,      messages: await convertToModelMessages(this.messages),      tools: {        deepResearch: tool({          description: "Research a topic in depth",          inputSchema: z.object({            topic: z.string().describe("The topic to research"),          }),          execute: async ({ topic }) => {            const { text } = await researchAgent.generate({              prompt: topic,            });            return { summary: text };          },        }),      },      stopWhen: stepCountIs(5),    });
    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
import {  ToolLoopAgent,  streamText,  convertToModelMessages,  tool,  stepCountIs,} from "ai";import { z } from "zod";
// Define a reusable research agent with its own tools and instructionsconst researchAgent = new ToolLoopAgent({  model: researchModel,  instructions: "You are a research assistant. Be thorough and cite sources.",  tools: { webSearch: webSearchTool },  stopWhen: stepCountIs(10),});
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const result = streamText({      model: orchestratorModel,      messages: await convertToModelMessages(this.messages),      tools: {        deepResearch: tool({          description: "Research a topic in depth",          inputSchema: z.object({            topic: z.string().describe("The topic to research"),          }),          execute: async ({ topic }) => {            const { text } = await researchAgent.generate({              prompt: topic,            });            return { summary: text };          },        }),      },      stopWhen: stepCountIs(5),    });
    return result.toUIMessageStreamResponse();  }}
```

The research agent runs in its own context — its token budget is separate from the orchestrator's. Only the summary goes back to the parent model.

Note

`ToolLoopAgent` is best suited for in-process subagents, not as a replacement for `streamText` in `onChatMessage` itself. The main `onChatMessage` benefits from direct access to `this.env`, `this.messages`, and `options.body` — things that a pre-configured `ToolLoopAgent` instance cannot reference.

#### Streaming progress with preliminary results

By default, a tool part appears as loading until `execute` returns. Use an async generator (`async function*`) to stream progress updates to the client while the tool is still working:

* [  JavaScript ](#tab-panel-5427)
* [  TypeScript ](#tab-panel-5428)

JavaScript

```
deepResearch: tool({  description: "Research a topic in depth",  inputSchema: z.object({    topic: z.string().describe("The topic to research"),  }),  async *execute({ topic }) {    // Preliminary result — the client sees "searching" immediately    yield { status: "searching", topic, summary: undefined };
    const { text } = await researchAgent.generate({ prompt: topic });
    // Final result — sent to the model for its next step    yield { status: "done", topic, summary: text };  },});
```

TypeScript

```
deepResearch: tool({  description: "Research a topic in depth",  inputSchema: z.object({    topic: z.string().describe("The topic to research"),  }),  async *execute({ topic }) {    // Preliminary result — the client sees "searching" immediately    yield { status: "searching", topic, summary: undefined };
    const { text } = await researchAgent.generate({ prompt: topic });
    // Final result — sent to the model for its next step    yield { status: "done", topic, summary: text };  },});
```

Each `yield` updates the tool part on the client in real-time (with `preliminary: true`). The last yielded value becomes the final output that the model sees.

This pattern is useful when:

* A task requires exploring large amounts of information that would bloat the main context
* You want to show real-time progress for long-running tools
* You want to parallelize independent research (multiple tool calls run concurrently)
* You need different models or system prompts for different subtasks

For more, refer to the [AI SDK Agents docs ↗](https://ai-sdk.dev/docs/agents/overview), [Subagents ↗](https://ai-sdk.dev/docs/agents/subagents), and [Preliminary Tool Results ↗](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#preliminary-tool-results).

## Multi-client sync

When multiple clients connect to the same agent instance, messages are automatically broadcast to all connections. If one client sends a message, all other connected clients receive the updated message list.

```
Client A ──── sendMessage("Hello") ────▶ AIChatAgent                                              │                                        persist + stream                                              │Client A ◀── CF_AGENT_USE_CHAT_RESPONSE ──────┤Client B ◀── CF_AGENT_CHAT_MESSAGES ──────────┘
```

The originating client receives the streaming response. All other clients receive the final messages via a `CF_AGENT_CHAT_MESSAGES` broadcast.

## API reference

### Exports

| Import path               | Exports                                                                                                                                                                                              |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @cloudflare/ai-chat       | AIChatAgent, createToolsFromClientSchemas, ClientToolSchema, ChatRecoveryContext, ChatRecoveryOptions, ChatRecoveryConfig, ChatRecoveryExhaustedContext, ResolvedChatRecoveryConfig, lifecycle types |
| @cloudflare/ai-chat/react | useAgentChat, extractClientToolSchemas, getToolPartState, getToolCallId, getToolInput, getToolOutput, getToolApproval                                                                                |
| @cloudflare/ai-chat/types | MessageType, OutgoingMessage, IncomingMessage                                                                                                                                                        |
| agents/chat               | Shared advanced chat primitives such as SaveMessagesResult, SaveMessagesOptions, CHAT\_MESSAGE\_TYPES, ROW\_MAX\_BYTES, and isReplayChunk()                                                          |

### WebSocket protocol

The chat protocol uses typed JSON messages over WebSocket:

| Message                            | Direction       | Purpose                     |
| ---------------------------------- | --------------- | --------------------------- |
| CF\_AGENT\_USE\_CHAT\_REQUEST      | Client → Server | Send a chat message         |
| CF\_AGENT\_USE\_CHAT\_RESPONSE     | Server → Client | Stream response chunks      |
| CF\_AGENT\_CHAT\_MESSAGES          | Server → Client | Broadcast updated messages  |
| CF\_AGENT\_CHAT\_CLEAR             | Bidirectional   | Clear conversation          |
| CF\_AGENT\_CHAT\_REQUEST\_CANCEL   | Client → Server | Cancel active stream        |
| CF\_AGENT\_TOOL\_RESULT            | Client → Server | Provide tool output         |
| CF\_AGENT\_TOOL\_APPROVAL          | Client → Server | Approve or reject a tool    |
| CF\_AGENT\_MESSAGE\_UPDATED        | Server → Client | Notify of message update    |
| CF\_AGENT\_STREAM\_RESUMING        | Server → Client | Notify of stream resumption |
| CF\_AGENT\_STREAM\_RESUME\_REQUEST | Client → Server | Request stream resume check |
| CF\_AGENT\_STREAM\_RESUME\_ACK     | Server → Client | Resume stream from a cursor |
| CF\_AGENT\_STREAM\_RESUME\_NONE    | Server → Client | No resumable stream exists  |

## Deprecated APIs

The following APIs are deprecated and will emit a console warning when used. They will be removed in a future release.

| Deprecated                            | Replacement                              | Notes                                           |
| ------------------------------------- | ---------------------------------------- | ----------------------------------------------- |
| addToolResult({ toolCallId, result }) | addToolOutput({ toolCallId, output })    | Renamed for consistency with AI SDK terminology |
| detectToolsRequiringConfirmation()    | Use needsApproval on the tool definition | Approval is now per-tool, not a global filter   |
| toolsRequiringConfirmation option     | Use needsApproval on individual tools    | Per-tool approval replaces global list          |

If you are upgrading from an earlier version, replace deprecated calls with their replacements. The deprecated APIs still work but will be removed in a future major version.

`createToolsFromClientSchemas()`, `extractClientToolSchemas()`, and the `tools` option on `useAgentChat` are still supported for dynamic client-side tools. They are advanced APIs, not deprecated APIs.

## Next steps

[ Client SDK ](https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/) useAgent hook and AgentClient class. 

[ Human-in-the-loop ](https://developers.cloudflare.com/agents/concepts/agentic-patterns/human-in-the-loop/) Approval flows and manual intervention patterns. 

[ Build a chat agent ](https://developers.cloudflare.com/agents/examples/chat-agent/) Step-by-step tutorial for building your first chat agent. 

[ Durable execution ](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/) runFiber(), stash(), and crash recovery for long-running work. 

[ Long-running agents ](https://developers.cloudflare.com/agents/concepts/agentic-patterns/long-running-agents/) Lifecycle, recovery patterns, and provider-specific strategies.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/#page","headline":"Chat agents · Cloudflare Agents docs","description":"Build AI chat interfaces with AIChatAgent and useAgentChat, including message persistence, streaming, and tool support.","url":"https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-26","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/communication-channels/","name":"Communication channels"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/communication-channels/chat/","name":"Chat"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/communication-channels/chat/chat-agents/","name":"Chat agents"}}]}
```

---

---
title: Client SDK
description: Connect to Cloudflare Agents from browsers or server runtimes using useAgent, AgentClient, and agentFetch.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Client SDK

Connect to agents from any JavaScript runtime — browsers, Node.js, Deno, Bun, or edge functions — using WebSockets or HTTP. The SDK provides real-time state synchronization, RPC method calls, and streaming responses.

## Overview

The client SDK offers two ways to connect with a WebSocket connection, and one way to make HTTP requests.

| Client      | Use Case                                                    |
| ----------- | ----------------------------------------------------------- |
| useAgent    | React hook with automatic reconnection and state management |
| AgentClient | Vanilla JavaScript/TypeScript class for any environment     |
| agentFetch  | HTTP requests when WebSocket is not needed                  |

All clients provide:

* **Bidirectional state sync** \- Push and receive state updates in real-time
* **RPC calls** \- Call agent methods with typed arguments and return values
* **Streaming** \- Handle chunked responses for AI completions
* **Auto-reconnection** \- Automatic reconnection with exponential backoff

## Quick start

### React

* [  JavaScript ](#tab-panel-5447)
* [  TypeScript ](#tab-panel-5448)

JavaScript

```
import { useAgent } from "agents/react";
function Chat() {  const agent = useAgent({    agent: "ChatAgent",    name: "room-123",    onStateUpdate: (state) => {      console.log("New state:", state);    },  });
  const sendMessage = async () => {    const response = await agent.call("sendMessage", ["Hello!"]);    console.log("Response:", response);  };
  return <button onClick={sendMessage}>Send</button>;}
```

TypeScript

```
import { useAgent } from "agents/react";
function Chat() {  const agent = useAgent({    agent: "ChatAgent",    name: "room-123",    onStateUpdate: (state) => {      console.log("New state:", state);    },  });
  const sendMessage = async () => {    const response = await agent.call("sendMessage", ["Hello!"]);    console.log("Response:", response);  };
  return <button onClick={sendMessage}>Send</button>;}
```

### Vanilla JavaScript

* [  JavaScript ](#tab-panel-5443)
* [  TypeScript ](#tab-panel-5444)

JavaScript

```
import { AgentClient } from "agents/client";
const client = new AgentClient({  agent: "ChatAgent",  name: "room-123",  host: "your-worker.your-subdomain.workers.dev",  onStateUpdate: (state) => {    console.log("New state:", state);  },});
// Call a methodconst response = await client.call("sendMessage", ["Hello!"]);
```

TypeScript

```
import { AgentClient } from "agents/client";
const client = new AgentClient({  agent: "ChatAgent",  name: "room-123",  host: "your-worker.your-subdomain.workers.dev",  onStateUpdate: (state) => {    console.log("New state:", state);  },});
// Call a methodconst response = await client.call("sendMessage", ["Hello!"]);
```

## Connecting to agents

### Agent naming

The `agent` parameter is your agent class name. It is automatically converted from camelCase to kebab-case for the URL:

* [  JavaScript ](#tab-panel-5439)
* [  TypeScript ](#tab-panel-5440)

JavaScript

```
// These are equivalent:useAgent({ agent: "ChatAgent" }); // → /agents/chat-agent/...useAgent({ agent: "MyCustomAgent" }); // → /agents/my-custom-agent/...useAgent({ agent: "LOUD_AGENT" }); // → /agents/loud-agent/...
```

TypeScript

```
// These are equivalent:useAgent({ agent: "ChatAgent" }); // → /agents/chat-agent/...useAgent({ agent: "MyCustomAgent" }); // → /agents/my-custom-agent/...useAgent({ agent: "LOUD_AGENT" }); // → /agents/loud-agent/...
```

### Instance names

The `name` parameter identifies a specific agent instance. If omitted, defaults to `"default"`:

* [  JavaScript ](#tab-panel-5441)
* [  TypeScript ](#tab-panel-5442)

JavaScript

```
// Connect to a specific chat roomuseAgent({ agent: "ChatAgent", name: "room-123" });
// Connect to a user's personal agentuseAgent({ agent: "UserAgent", name: userId });
// Uses "default" instanceuseAgent({ agent: "ChatAgent" });
```

TypeScript

```
// Connect to a specific chat roomuseAgent({ agent: "ChatAgent", name: "room-123" });
// Connect to a user's personal agentuseAgent({ agent: "UserAgent", name: userId });
// Uses "default" instanceuseAgent({ agent: "ChatAgent" });
```

### Connection options

Both `useAgent` and `AgentClient` accept connection options:

* [  JavaScript ](#tab-panel-5457)
* [  TypeScript ](#tab-panel-5458)

JavaScript

```
useAgent({  agent: "ChatAgent",  name: "room-123",
  // Connection settings  host: "my-worker.workers.dev", // Custom host (defaults to current origin)  path: "/custom/path", // Custom path prefix
  // Query parameters (sent on connection)  query: {    token: "abc123",    version: "2",  },
  // Event handlers  onOpen: () => console.log("Connected"),  onClose: () => console.log("Disconnected"),  onError: (error) => console.error("Error:", error),});
```

TypeScript

```
useAgent({  agent: "ChatAgent",  name: "room-123",
  // Connection settings  host: "my-worker.workers.dev", // Custom host (defaults to current origin)  path: "/custom/path", // Custom path prefix
  // Query parameters (sent on connection)  query: {    token: "abc123",    version: "2",  },
  // Event handlers  onOpen: () => console.log("Connected"),  onClose: () => console.log("Disconnected"),  onError: (error) => console.error("Error:", error),});
```

### Async query parameters

For authentication tokens or other async data, pass a function that returns a Promise:

* [  JavaScript ](#tab-panel-5453)
* [  TypeScript ](#tab-panel-5454)

JavaScript

```
useAgent({  agent: "ChatAgent",  name: "room-123",
  // Async query - called before connecting  query: async () => {    const token = await getAuthToken();    return { token };  },
  // Dependencies that trigger re-fetching the query  queryDeps: [userId],
  // Cache TTL for the query result (default: 5 minutes)  cacheTtl: 60 * 1000, // 1 minute});
```

TypeScript

```
useAgent({  agent: "ChatAgent",  name: "room-123",
  // Async query - called before connecting  query: async () => {    const token = await getAuthToken();    return { token };  },
  // Dependencies that trigger re-fetching the query  queryDeps: [userId],
  // Cache TTL for the query result (default: 5 minutes)  cacheTtl: 60 * 1000, // 1 minute});
```

The query function is cached and only re-called when:

* `queryDeps` change
* `cacheTtl` expires
* The WebSocket connection closes (automatic cache invalidation)
* The component remounts

Automatic cache invalidation on disconnect

When the WebSocket connection closes — whether due to network issues, server restarts, or explicit disconnection — the async query cache is automatically invalidated. This ensures that when the client reconnects, the query function is re-executed to fetch fresh data. This is particularly important for authentication tokens that may have expired during the disconnection period.

## State synchronization

Agents can maintain state that syncs bidirectionally with all connected clients.

### Reading current state

Both `useAgent` and `AgentClient` expose a `state` property that reflects the current agent state. It starts as `undefined` until the first state message is received from the server.

* [  JavaScript ](#tab-panel-5445)
* [  TypeScript ](#tab-panel-5446)

JavaScript

```
const agent = useAgent({ agent: "GameAgent", name: "game-123" });
// Read the current state at any timeconsole.log("Current score:", agent.state?.score);
```

TypeScript

```
const agent = useAgent({ agent: "GameAgent", name: "game-123" });
// Read the current state at any timeconsole.log("Current score:", agent.state?.score);
```

With `useAgent`, state updates trigger a React re-render, so `agent.state` always reflects the latest value in your JSX. With `AgentClient`, the `state` field is updated synchronously on each incoming server broadcast or `setState` call.

### Receiving state updates

* [  JavaScript ](#tab-panel-5451)
* [  TypeScript ](#tab-panel-5452)

JavaScript

```
const agent = useAgent({  agent: "GameAgent",  name: "game-123",  onStateUpdate: (state, source) => {    // state: The new state from the agent    // source: "server" (agent pushed) or "client" (you pushed)    console.log(`State updated from ${source}:`, state);    setGameState(state);  },});
```

TypeScript

```
const agent = useAgent({  agent: "GameAgent",  name: "game-123",  onStateUpdate: (state, source) => {    // state: The new state from the agent    // source: "server" (agent pushed) or "client" (you pushed)    console.log(`State updated from ${source}:`, state);    setGameState(state);  },});
```

### Pushing state updates

* [  JavaScript ](#tab-panel-5449)
* [  TypeScript ](#tab-panel-5450)

JavaScript

```
// Update the agent's state from the clientagent.setState({ score: 100, level: 5 });
```

TypeScript

```
// Update the agent's state from the clientagent.setState({ score: 100, level: 5 });
```

When you call `setState()`:

1. The state is sent to the agent over WebSocket
2. The agent's `onStateChanged()` method is called
3. The agent broadcasts the new state to all connected clients
4. Your `onStateUpdate` callback fires with `source: "client"`

### State flow

sequenceDiagram
    participant Client
    participant Agent
    Client->>Agent: setState()
    Agent-->>Client: onStateUpdate (broadcast)

## Calling agent methods (RPC)

Call methods on your agent that are decorated with `@callable()`.

Note

The `@callable()` decorator is only required for methods called from external runtimes (browsers, other services). When calling from within the same Worker, you can use standard [Durable Object RPC](https://developers.cloudflare.com/durable-objects/best-practices/create-durable-object-stubs-and-send-requests/#invoke-rpc-methods) directly on the stub without the decorator.

### Using call()

* [  JavaScript ](#tab-panel-5455)
* [  TypeScript ](#tab-panel-5456)

JavaScript

```
// Basic callconst result = await agent.call("getUser", [userId]);
// Call with multiple argumentsconst result = await agent.call("createPost", [title, content, tags]);
// Call with no argumentsconst result = await agent.call("getStats");
```

TypeScript

```
// Basic callconst result = await agent.call("getUser", [userId]);
// Call with multiple argumentsconst result = await agent.call("createPost", [title, content, tags]);
// Call with no argumentsconst result = await agent.call("getStats");
```

### Using the stub proxy

The `stub` property provides a cleaner syntax for method calls:

* [  JavaScript ](#tab-panel-5459)
* [  TypeScript ](#tab-panel-5460)

JavaScript

```
// Instead of:const user = await agent.call("getUser", ["user-123"]);
// You can write:const user = await agent.stub.getUser("user-123");
// Multiple arguments work naturally:const post = await agent.stub.createPost(title, content, tags);
```

TypeScript

```
// Instead of:const user = await agent.call("getUser", ["user-123"]);
// You can write:const user = await agent.stub.getUser("user-123");
// Multiple arguments work naturally:const post = await agent.stub.createPost(title, content, tags);
```

### TypeScript integration

For full type safety, pass your Agent class as a type parameter:

* [  JavaScript ](#tab-panel-5461)
* [  TypeScript ](#tab-panel-5462)

JavaScript

```
const agent = useAgent({  agent: "MyAgent",  name: "instance-1",});
// Now stub methods are fully typedconst result = await agent.stub.processData({ input: "test" });
```

TypeScript

```
import type { MyAgent } from "./agents/my-agent";
const agent = useAgent<MyAgent>({  agent: "MyAgent",  name: "instance-1",});
// Now stub methods are fully typedconst result = await agent.stub.processData({ input: "test" });
```

### Streaming responses

For methods that return `StreamingResponse`, handle chunks as they arrive:

* [  JavaScript ](#tab-panel-5479)
* [  TypeScript ](#tab-panel-5480)

JavaScript

```
// Agent-side:class MyAgent extends Agent {  @callable({ streaming: true })  async generateText(stream, prompt) {    for await (const chunk of llm.stream(prompt)) {      await stream.write(chunk);    }  }}
// Client-side:await agent.call("generateText", [prompt], {  onChunk: (chunk) => {    // Called for each chunk    appendToOutput(chunk);  },  onDone: (finalResult) => {    // Called when stream completes    console.log("Complete:", finalResult);  },  onError: (error) => {    // Called if streaming fails    console.error("Stream error:", error);  },});
```

TypeScript

```
// Agent-side:class MyAgent extends Agent {  @callable({ streaming: true })  async generateText(stream: StreamingResponse, prompt: string) {    for await (const chunk of llm.stream(prompt)) {      await stream.write(chunk);    }  }}
// Client-side:await agent.call("generateText", [prompt], {  onChunk: (chunk) => {    // Called for each chunk    appendToOutput(chunk);  },  onDone: (finalResult) => {    // Called when stream completes    console.log("Complete:", finalResult);  },  onError: (error) => {    // Called if streaming fails    console.error("Stream error:", error);  },});
```

## HTTP requests with agentFetch

For one-off requests without maintaining a WebSocket connection:

* [  JavaScript ](#tab-panel-5481)
* [  TypeScript ](#tab-panel-5482)

JavaScript

```
import { agentFetch } from "agents/client";
// GET requestconst response = await agentFetch({  agent: "DataAgent",  name: "instance-1",  host: "my-worker.workers.dev",});
const data = await response.json();
// POST request with bodyconst response = await agentFetch(  {    agent: "DataAgent",    name: "instance-1",    host: "my-worker.workers.dev",  },  {    method: "POST",    headers: { "Content-Type": "application/json" },    body: JSON.stringify({ action: "process" }),  },);
```

TypeScript

```
import { agentFetch } from "agents/client";
// GET requestconst response = await agentFetch({  agent: "DataAgent",  name: "instance-1",  host: "my-worker.workers.dev",});
const data = await response.json();
// POST request with bodyconst response = await agentFetch(  {    agent: "DataAgent",    name: "instance-1",    host: "my-worker.workers.dev",  },  {    method: "POST",    headers: { "Content-Type": "application/json" },    body: JSON.stringify({ action: "process" }),  },);
```

**When to use `agentFetch` vs WebSocket:**

| Use agentFetch                  | Use useAgent/AgentClient    |
| ------------------------------- | --------------------------- |
| One-time requests               | Real-time updates needed    |
| Server-to-server calls          | Bidirectional communication |
| Simple REST-style API           | State synchronization       |
| No persistent connection needed | Multiple RPC calls          |

## MCP server integration

If your agent uses MCP (Model Context Protocol) servers, you can receive updates about their state:

* [  JavaScript ](#tab-panel-5465)
* [  TypeScript ](#tab-panel-5466)

JavaScript

```
const agent = useAgent({  agent: "AssistantAgent",  name: "session-123",  onMcpUpdate: (mcpServers) => {    // mcpServers is a record of server states    for (const [serverId, server] of Object.entries(mcpServers)) {      console.log(`${serverId}: ${server.connectionState}`);      console.log(`Tools: ${server.tools?.map((t) => t.name).join(", ")}`);    }  },});
```

TypeScript

```
const agent = useAgent({  agent: "AssistantAgent",  name: "session-123",  onMcpUpdate: (mcpServers) => {    // mcpServers is a record of server states    for (const [serverId, server] of Object.entries(mcpServers)) {      console.log(`${serverId}: ${server.connectionState}`);      console.log(`Tools: ${server.tools?.map((t) => t.name).join(", ")}`);    }  },});
```

## Error handling

### Connection errors

* [  JavaScript ](#tab-panel-5467)
* [  TypeScript ](#tab-panel-5468)

JavaScript

```
const agent = useAgent({  agent: "MyAgent",  onError: (error) => {    console.error("WebSocket error:", error);  },  onClose: () => {    console.log("Connection closed, will auto-reconnect...");  },});
```

TypeScript

```
const agent = useAgent({  agent: "MyAgent",  onError: (error) => {    console.error("WebSocket error:", error);  },  onClose: () => {    console.log("Connection closed, will auto-reconnect...");  },});
```

### RPC errors

* [  JavaScript ](#tab-panel-5463)
* [  TypeScript ](#tab-panel-5464)

JavaScript

```
try {  const result = await agent.call("riskyMethod", [data]);} catch (error) {  // Error thrown by the agent method  console.error("RPC failed:", error.message);}
```

TypeScript

```
try {  const result = await agent.call("riskyMethod", [data]);} catch (error) {  // Error thrown by the agent method  console.error("RPC failed:", error.message);}
```

### Streaming errors

* [  JavaScript ](#tab-panel-5469)
* [  TypeScript ](#tab-panel-5470)

JavaScript

```
await agent.call("streamingMethod", [data], {  onChunk: (chunk) => handleChunk(chunk),  onError: (errorMessage) => {    // Stream-specific error handling    console.error("Stream error:", errorMessage);  },});
```

TypeScript

```
await agent.call("streamingMethod", [data], {  onChunk: (chunk) => handleChunk(chunk),  onError: (errorMessage) => {    // Stream-specific error handling    console.error("Stream error:", errorMessage);  },});
```

## Best practices

### 1\. Use typed stubs

* [  JavaScript ](#tab-panel-5471)
* [  TypeScript ](#tab-panel-5472)

JavaScript

```
// Prefer this:const user = await agent.stub.getUser(id);
// Over this:const user = await agent.call("getUser", [id]);
```

TypeScript

```
// Prefer this:const user = await agent.stub.getUser(id);
// Over this:const user = await agent.call("getUser", [id]);
```

### 2\. Reconnection is automatic

The client auto-reconnects and the agent automatically sends the current state on each connection. Your `onStateUpdate` callback will fire with the latest state — no manual re-sync is needed. If you use an async `query` function for authentication, the cache is automatically invalidated on disconnect, ensuring fresh tokens are fetched on reconnect.

### 3\. Optimize query caching

* [  JavaScript ](#tab-panel-5473)
* [  TypeScript ](#tab-panel-5474)

JavaScript

```
// For auth tokens that expire hourly:useAgent({  query: async () => ({ token: await getToken() }),  cacheTtl: 55 * 60 * 1000, // Refresh 5 min before expiry  queryDeps: [userId], // Refresh if user changes});
```

TypeScript

```
// For auth tokens that expire hourly:useAgent({  query: async () => ({ token: await getToken() }),  cacheTtl: 55 * 60 * 1000, // Refresh 5 min before expiry  queryDeps: [userId], // Refresh if user changes});
```

### 4\. Clean up connections

In vanilla JS, close connections when done:

* [  JavaScript ](#tab-panel-5475)
* [  TypeScript ](#tab-panel-5476)

JavaScript

```
const client = new AgentClient({ agent: "MyAgent", host: "..." });
// When done:client.close();
```

TypeScript

```
const client = new AgentClient({ agent: "MyAgent", host: "..." });
// When done:client.close();
```

React's `useAgent` handles cleanup automatically on unmount.

## React hook reference

### UseAgentOptions

TypeScript

```
type UseAgentOptions<State> = {  // Required  agent: string; // Agent class name
  // Optional  name?: string; // Instance name (default: "default")  host?: string; // Custom host  path?: string; // Custom path prefix
  // Query parameters  query?: Record<string, string> | (() => Promise<Record<string, string>>);  queryDeps?: unknown[]; // Dependencies for async query  cacheTtl?: number; // Query cache TTL in ms (default: 5 min)
  // Callbacks  onStateUpdate?: (state: State, source: "server" | "client") => void;  onMcpUpdate?: (mcpServers: MCPServersState) => void;  onOpen?: () => void;  onClose?: () => void;  onError?: (error: Event) => void;  onMessage?: (message: MessageEvent) => void;};
```

### Return value

The `useAgent` hook returns an object with the following properties and methods:

| Property/Method               | Type    | Description                |
| ----------------------------- | ------- | -------------------------- |
| agent                         | string  | Kebab-case agent name      |
| name                          | string  | Instance name              |
| setState(state)               | void    | Push state to agent        |
| call(method, args?, options?) | Promise | Call agent method          |
| stub                          | Proxy   | Typed method calls         |
| send(data)                    | void    | Send raw WebSocket message |
| close()                       | void    | Close connection           |
| reconnect()                   | void    | Force reconnection         |

## Vanilla JS reference

### AgentClientOptions

TypeScript

```
type AgentClientOptions<State> = {  // Required  agent: string; // Agent class name  host: string; // Worker host
  // Optional  name?: string; // Instance name (default: "default")  path?: string; // Custom path prefix  query?: Record<string, string>;
  // Callbacks  onStateUpdate?: (state: State, source: "server" | "client") => void;};
```

### AgentClient methods

| Property/Method               | Type    | Description                |
| ----------------------------- | ------- | -------------------------- |
| agent                         | string  | Kebab-case agent name      |
| name                          | string  | Instance name              |
| setState(state)               | void    | Push state to agent        |
| call(method, args?, options?) | Promise | Call agent method          |
| send(data)                    | void    | Send raw WebSocket message |
| close()                       | void    | Close connection           |
| reconnect()                   | void    | Force reconnection         |

The client also supports WebSocket event listeners:

* [  JavaScript ](#tab-panel-5477)
* [  TypeScript ](#tab-panel-5478)

JavaScript

```
client.addEventListener("open", () => {});client.addEventListener("close", () => {});client.addEventListener("error", () => {});client.addEventListener("message", () => {});
```

TypeScript

```
client.addEventListener("open", () => {});client.addEventListener("close", () => {});client.addEventListener("error", () => {});client.addEventListener("message", () => {});
```

## Agent-tool events

If your chat UI renders retained child runs from [Agents as tools](https://developers.cloudflare.com/agents/runtime/execution/agent-tools/), use `useAgentToolEvents()` alongside `useAgent()` and `useAgentChat()`. The hook subscribes to the parent connection, replays retained child timelines, and groups runs by parent tool call ID.

* [  JavaScript ](#tab-panel-5483)
* [  TypeScript ](#tab-panel-5484)

JavaScript

```
import { useAgent, useAgentToolEvents } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
const agent = useAgent({ agent: "Assistant", name: userId });const { messages } = useAgentChat({ agent });const agentTools = useAgentToolEvents({ agent });
```

TypeScript

```
import { useAgent, useAgentToolEvents } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
const agent = useAgent({ agent: "Assistant", name: userId });const { messages } = useAgentChat({ agent });const agentTools = useAgentToolEvents({ agent });
```

## Next steps

[ Routing ](https://developers.cloudflare.com/agents/runtime/communication/routing/) URL patterns and custom routing options. 

[ Callable methods ](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/) RPC over WebSocket for client-server method calls. 

[ Cross-domain authentication ](https://developers.cloudflare.com/agents/runtime/operations/cross-domain-authentication/) Secure WebSocket connections across domains. 

[ Build a chat agent ](https://developers.cloudflare.com/agents/examples/chat-agent/) Complete client integration with AI chat.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/#page","headline":"Client SDK · Cloudflare Agents docs","description":"Connect to Cloudflare Agents from browsers or server runtimes using useAgent, AgentClient, and agentFetch.","url":"https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-09","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/communication-channels/","name":"Communication channels"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/communication-channels/chat/","name":"Chat"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/communication-channels/chat/client-sdk/","name":"Client SDK"}}]}
```

---

---
title: Email
description: Connect agents to email so they can send outbound messages, process inbound mail, and handle follow-up replies.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Email

Email is a communication channel for agents that need to interact with users or systems through inboxes instead of chat UIs. Agents can send outbound email, receive inbound email, route replies back to an existing session, and use email content as part of an agent workflow.

Use email when you want an agent to:

* Send notifications, summaries, receipts, or follow-up messages.
* Process inbound messages through [Cloudflare Email Service](https://developers.cloudflare.com/email-service/).
* Continue a conversation from a reply.
* Route support, sales, or operational workflows through an agent.

## How it works

Outbound email uses a `send_email` binding in your Worker. Inbound email uses an Email Service routing rule that sends messages to your Worker, where the agent can parse the sender, recipients, headers, and body before deciding how to respond.

For reply handling, include a stable identifier in the reply address, message metadata, or headers so the Worker can route follow-up messages to the right agent instance.

## Basic pattern

Implement `onEmail()` to handle inbound email, and use `sendEmail()` or `replyToEmail()` when the agent needs to send a response.

* [  JavaScript ](#tab-panel-5487)
* [  TypeScript ](#tab-panel-5488)

JavaScript

```
import { Agent, callable, routeAgentEmail } from "agents";import { createAddressBasedEmailResolver } from "agents/email";
export class EmailAgent extends Agent {  @callable()  async sendWelcomeEmail(to) {    await this.sendEmail({      binding: this.env.EMAIL,      to,      from: "support@yourdomain.com",      replyTo: "support@yourdomain.com",      subject: "Welcome",      text: "Thanks for signing up. Reply to this email if you need help.",    });  }
  async onEmail(email) {    await this.replyToEmail(email, {      fromName: "Support Agent",      body: "Thanks for your email. We received it.",    });  }}
export default {  async email(message, env) {    await routeAgentEmail(message, env, {      resolver: createAddressBasedEmailResolver("EmailAgent"),    });  },};
```

TypeScript

```
import { Agent, callable, routeAgentEmail } from "agents";import { createAddressBasedEmailResolver, type AgentEmail } from "agents/email";
export class EmailAgent extends Agent {  @callable()  async sendWelcomeEmail(to: string) {    await this.sendEmail({      binding: this.env.EMAIL,      to,      from: "support@yourdomain.com",      replyTo: "support@yourdomain.com",      subject: "Welcome",      text: "Thanks for signing up. Reply to this email if you need help.",    });  }
  async onEmail(email: AgentEmail) {    await this.replyToEmail(email, {      fromName: "Support Agent",      body: "Thanks for your email. We received it.",    });  }}
export default {  async email(message, env) {    await routeAgentEmail(message, env, {      resolver: createAddressBasedEmailResolver("EmailAgent"),    });  },} satisfies ExportedHandler<Env>;
```

## Configuration

Add a `send_email` binding for outbound email, then configure an Email Service routing rule to send inbound mail to your Worker.

* [  wrangler.jsonc ](#tab-panel-5485)
* [  wrangler.toml ](#tab-panel-5486)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  "send_email": [    {      "name": "EMAIL",      "remote": true    }  ]}
```

TOML

```
[[send_email]]name = "EMAIL"remote = true
```

The `remote = true` option lets you call the real Email Service API during local development with `wrangler dev`.

## Build an email agent

For a complete walkthrough, including domain setup, bindings, inbound routing, and secure replies, use the email agent example.

[ Email agent ](https://developers.cloudflare.com/agents/examples/email-agent/) Build an agent that sends, receives, routes, and replies to email using Cloudflare Email Service and the Agents SDK. 

## Related resources

[ Email Service ](https://developers.cloudflare.com/email-service/) Route, receive, and send email with Cloudflare Email Service. 

[ Send email from Workers ](https://developers.cloudflare.com/email-service/api/send-emails/workers-api/) Use the Workers API to send outbound email.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/communication-channels/email/#page","headline":"Email · Cloudflare Agents docs","description":"Connect agents to email so they can send outbound messages, process inbound mail, and handle follow-up replies.","url":"https://developers.cloudflare.com/agents/communication-channels/email/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-09","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/communication-channels/","name":"Communication channels"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/communication-channels/email/","name":"Email"}}]}
```

---

---
title: Slack
description: Connect agents to Slack workspaces so they can respond to direct messages, mentions, and threaded conversations.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Slack

Slack is a communication channel for agents that need to participate in team conversations. A Slack-connected agent can receive events from Slack, route each message to the right agent instance, and respond back to direct messages or channel mentions.

Use Slack when you want an agent to:

* Respond to direct messages from Slack users.
* Reply when mentioned in public channels.
* Maintain context inside Slack threads.
* Serve multiple Slack workspaces from one deployment.

## How it works

Slack sends events to your Worker through the [Slack Events API ↗](https://api.slack.com/apis/events-api). Your Worker verifies each request, identifies the installed workspace, and routes the event to an agent instance.

Common Slack events include:

| Event        | Use case                   |
| ------------ | -------------------------- |
| message.im   | Direct messages to the bot |
| app\_mention | Mentions in channels       |

For multi-workspace Slack apps, store each workspace installation separately and route events by team or enterprise ID. Each workspace can map to an isolated agent instance with its own Durable Object-backed state.

## Build a Slack agent

For a complete walkthrough, including Slack app setup, OAuth, event subscriptions, and deployment, use the Slack agent example.

[ Slack agent ](https://developers.cloudflare.com/agents/examples/slack-agent/) Build and deploy an AI-powered Slack bot on Cloudflare Workers using the Agents SDK. 

## Related resources

[ Slack Events API ](https://api.slack.com/apis/events-api) Receive events when users message, mention, or interact with a Slack app. 

[ Slack app authentication ](https://api.slack.com/authentication) Configure OAuth, bot tokens, signing secrets, and request verification for Slack apps.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/communication-channels/slack/#page","headline":"Slack · Cloudflare Agents docs","description":"Connect agents to Slack workspaces so they can respond to direct messages, mentions, and threaded conversations.","url":"https://developers.cloudflare.com/agents/communication-channels/slack/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/communication-channels/","name":"Communication channels"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/communication-channels/slack/","name":"Slack"}}]}
```

---

---
title: Voice
description: Build real-time voice agents with speech-to-text, text-to-speech, and conversation persistence over WebSocket.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Voice

Build real-time voice agents with speech-to-text, text-to-speech, and conversation persistence. Audio streams over WebSocket — no SFU or meeting infrastructure required. Beta

## Overview

`@cloudflare/voice` provides two server-side mixins and matching client libraries:

| Export         | Import                   | Purpose                                      |
| -------------- | ------------------------ | -------------------------------------------- |
| withVoice      | @cloudflare/voice        | Full voice agent: STT, LLM, TTS, persistence |
| withVoiceInput | @cloudflare/voice        | STT-only: transcription without response     |
| useVoiceAgent  | @cloudflare/voice/react  | React hook for withVoice agents              |
| useVoiceInput  | @cloudflare/voice/react  | React hook for withVoiceInput agents         |
| VoiceClient    | @cloudflare/voice/client | Framework-agnostic client                    |

Built on Cloudflare Durable Objects, you get:

* **Real-time audio** — mic audio streams as binary WebSocket frames, TTS audio streams back
* **Automatic conversation persistence** — messages stored in SQLite, survive restarts
* **Streaming TTS** — LLM tokens are sentence-chunked and synthesized concurrently
* **Interruption handling** — user speech during playback cancels the current response
* **Continuous STT** — per-call transcriber session, model handles turn detection
* **Pipeline hooks** — intercept and transform text at every stage

## Quick start

### Install

Terminal window

```
npm install @cloudflare/voice agents
```

### Server

* [  JavaScript ](#tab-panel-5499)
* [  TypeScript ](#tab-panel-5500)

JavaScript

```
import { Agent } from "agents";import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
export class MyAgent extends VoiceAgent {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);
  async onTurn(transcript, context) {    return "Hello! I heard you say: " + transcript;  }}
```

TypeScript

```
import { Agent } from "agents";import {  withVoice,  WorkersAIFluxSTT,  WorkersAITTS,  type VoiceTurnContext,} from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
export class MyAgent extends VoiceAgent<Env> {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);
  async onTurn(transcript: string, context: VoiceTurnContext) {    return "Hello! I heard you say: " + transcript;  }}
```

### Client (React)

```
import { useVoiceAgent } from "@cloudflare/voice/react";
function VoiceUI() {  const {    status,    transcript,    interimTranscript,    audioLevel,    isMuted,    startCall,    endCall,    toggleMute,  } = useVoiceAgent({ agent: "MyAgent" });
  return (    <div>      <p>Status: {status}</p>
      <button onClick={status === "idle" ? startCall : endCall}>        {status === "idle" ? "Start Call" : "End Call"}      </button>
      <button onClick={toggleMute}>{isMuted ? "Unmute" : "Mute"}</button>
      {interimTranscript && (        <p>          <em>{interimTranscript}</em>        </p>      )}
      {transcript.map((msg, i) => (        <p key={i}>          <strong>{msg.role}:</strong> {msg.text}        </p>      ))}    </div>  );}
```

### Wrangler configuration

* [  wrangler.jsonc ](#tab-panel-5489)
* [  wrangler.toml ](#tab-panel-5490)

JSONC

```
{  "ai": {    "binding": "AI"  },  "durable_objects": {    "bindings": [      {        "name": "MyAgent",        "class_name": "MyAgent"      }    ]  },  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": ["MyAgent"]    }  ]}
```

TOML

```
[ai]binding = "AI"
[[durable_objects.bindings]]name = "MyAgent"class_name = "MyAgent"
[[migrations]]tag = "v1"new_sqlite_classes = [ "MyAgent" ]
```

## How it works

```
Browser                              Durable Object (withVoice)┌──────────┐                         ┌──────────────────────────┐│ Mic      │   binary PCM (16kHz)    │ Transcriber session      ││          │ ──────────────────────► │ (per-call, continuous)   ││          │                         │   ↓ model detects turn   ││          │   JSON: transcript      │ onTurn() → your LLM code ││          │ ◄────────────────────── │   ↓ (sentence chunking)  ││          │   binary: audio         │ TTS                      ││ Speaker  │ ◄────────────────────── │                          │└──────────┘                         └──────────────────────────┘
```

1. The client captures mic audio and sends it as binary WebSocket frames (16kHz mono 16-bit PCM).
2. Audio streams continuously to the transcriber session (created at `start_call`, lives for the entire call).
3. The STT model detects when the user finishes an utterance and fires `onUtterance`. All providers use **model-driven turn detection** — the client does not need to signal end-of-speech for STT.
4. Your `onTurn()` method runs — typically an LLM call.
5. The response is sentence-chunked and synthesized via TTS.
6. Audio streams back to the client for playback.

The client receives `transcript_interim` messages with partial results as the user speaks, so you can show real-time feedback in the UI.

## Server API: `withVoice`

`withVoice(Agent)` adds the full voice pipeline to an Agent class.

### Providers

Set providers as class properties. Class field initializers run after `super()`, so `this.env` is available.

| Property    | Type        | Required | Description                      |
| ----------- | ----------- | -------- | -------------------------------- |
| transcriber | Transcriber | Yes      | Continuous per-call STT provider |
| tts         | TTSProvider | Yes      | Text-to-speech                   |

* [  JavaScript ](#tab-panel-5491)
* [  TypeScript ](#tab-panel-5492)

JavaScript

```
import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
export class MyAgent extends VoiceAgent {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);}
```

TypeScript

```
import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
export class MyAgent extends VoiceAgent<Env> {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);}
```

For runtime model switching (for example, a Flux vs Nova 3 dropdown), override `createTranscriber`:

* [  JavaScript ](#tab-panel-5493)
* [  TypeScript ](#tab-panel-5494)

JavaScript

```
export class MyAgent extends VoiceAgent {  tts = new WorkersAITTS(this.env.AI);
  createTranscriber(connection) {    return new WorkersAIFluxSTT(this.env.AI);  }}
```

TypeScript

```
export class MyAgent extends VoiceAgent<Env> {  tts = new WorkersAITTS(this.env.AI);
  createTranscriber(connection: Connection): Transcriber {    return new WorkersAIFluxSTT(this.env.AI);  }}
```

### `onTurn(transcript, context)`

**Required.** Called when the user finishes speaking and the transcript is ready.

Return a `string`, `AsyncIterable<string>`, or `ReadableStream` for streaming responses.

**Simple response:**

* [  JavaScript ](#tab-panel-5495)
* [  TypeScript ](#tab-panel-5496)

JavaScript

```
export class MyAgent extends VoiceAgent {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);
  async onTurn(transcript, context) {    return "You said: " + transcript;  }}
```

TypeScript

```
export class MyAgent extends VoiceAgent<Env> {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);
  async onTurn(transcript: string, context: VoiceTurnContext) {    return "You said: " + transcript;  }}
```

**Streaming response (recommended for LLM):**

* [  JavaScript ](#tab-panel-5513)
* [  TypeScript ](#tab-panel-5514)

JavaScript

```
import { streamText } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class MyAgent extends VoiceAgent {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);
  async onTurn(transcript, context) {    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/moonshotai/kimi-k2.6"),      system: "You are a helpful voice assistant. Keep responses concise.",      messages: [        ...context.messages.map((m) => ({          role: m.role,          content: m.content,        })),        { role: "user", content: transcript },      ],      abortSignal: context.signal,    });
    return result.textStream;  }}
```

TypeScript

```
import { streamText } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class MyAgent extends VoiceAgent<Env> {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);
  async onTurn(transcript: string, context: VoiceTurnContext) {    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/moonshotai/kimi-k2.6"),      system: "You are a helpful voice assistant. Keep responses concise.",      messages: [        ...context.messages.map((m) => ({          role: m.role as "user" | "assistant",          content: m.content,        })),        { role: "user", content: transcript },      ],      abortSignal: context.signal,    });
    return result.textStream;  }}
```

The `context` object provides:

| Field      | Type                                     | Description                        |
| ---------- | ---------------------------------------- | ---------------------------------- |
| connection | Connection                               | The WebSocket connection           |
| messages   | Array<{ role: string; content: string }> | Conversation history from SQLite   |
| signal     | AbortSignal                              | Aborted on interrupt or disconnect |

### Lifecycle hooks

| Method                      | Description                                 |
| --------------------------- | ------------------------------------------- |
| beforeCallStart(connection) | Return false to reject the call             |
| onCallStart(connection)     | Called after a call is accepted             |
| onCallEnd(connection)       | Called when a call ends                     |
| onInterrupt(connection)     | Called when user interrupts during playback |

### Pipeline hooks

Intercept and transform data at each pipeline stage. Return `null` to skip the current utterance.

| Method                                   | Receives        | Can skip? |
| ---------------------------------------- | --------------- | --------- |
| afterTranscribe(transcript, connection)  | STT text        | Yes       |
| beforeSynthesize(text, connection)       | Text before TTS | Yes       |
| afterSynthesize(audio, text, connection) | Audio after TTS | Yes       |

* [  JavaScript ](#tab-panel-5505)
* [  TypeScript ](#tab-panel-5506)

JavaScript

```
import {} from "agents";
export class MyAgent extends VoiceAgent {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);
  afterTranscribe(transcript, connection) {    if (transcript.length < 3) return null;    return transcript;  }
  beforeSynthesize(text, connection) {    return text.replace(/\bAI\b/g, "A.I.");  }
  async onTurn(transcript, context) {    return transcript;  }}
```

TypeScript

```
import { type Connection } from "agents";
export class MyAgent extends VoiceAgent<Env> {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);
  afterTranscribe(transcript: string, connection: Connection) {    if (transcript.length < 3) return null;    return transcript;  }
  beforeSynthesize(text: string, connection: Connection) {    return text.replace(/\bAI\b/g, "A.I.");  }
  async onTurn(transcript: string, context: VoiceTurnContext) {    return transcript;  }}
```

### Convenience methods

| Method                   | Description                                  |
| ------------------------ | -------------------------------------------- |
| speak(connection, text)  | Synthesize and send audio to one connection  |
| speakAll(text)           | Synthesize and send audio to all connections |
| forceEndCall(connection) | Programmatically end a call                  |
| saveMessage(role, text)  | Persist a message to conversation history    |
| getConversationHistory() | Retrieve conversation history from SQLite    |

### Configuration options

Pass options to `withVoice()` as the second argument:

* [  JavaScript ](#tab-panel-5497)
* [  TypeScript ](#tab-panel-5498)

JavaScript

```
const VoiceAgent = withVoice(Agent, {  historyLimit: 20,  audioFormat: "mp3",  maxMessageCount: 1000,});
```

TypeScript

```
const VoiceAgent = withVoice(Agent, {  historyLimit: 20,  audioFormat: "mp3",  maxMessageCount: 1000,});
```

| Option          | Type   | Default | Description                     |
| --------------- | ------ | ------- | ------------------------------- |
| historyLimit    | number | 20      | Max messages loaded for context |
| audioFormat     | string | "mp3"   | Audio format sent to client     |
| maxMessageCount | number | 1000    | Max messages stored in SQLite   |

## Server API: `withVoiceInput`

`withVoiceInput(Agent)` adds STT-only voice input — no TTS, no LLM, no response generation. Use this for dictation, search-by-voice, or any UI where you need speech-to-text without a conversational agent.

* [  JavaScript ](#tab-panel-5503)
* [  TypeScript ](#tab-panel-5504)

JavaScript

```
import { Agent } from "agents";import { withVoiceInput, WorkersAINova3STT } from "@cloudflare/voice";
const InputAgent = withVoiceInput(Agent);
export class DictationAgent extends InputAgent {  transcriber = new WorkersAINova3STT(this.env.AI);
  onTranscript(text, connection) {    console.log("User said:", text);  }}
```

TypeScript

```
import { Agent } from "agents";import { withVoiceInput, WorkersAINova3STT } from "@cloudflare/voice";
const InputAgent = withVoiceInput(Agent);
export class DictationAgent extends InputAgent<Env> {  transcriber = new WorkersAINova3STT(this.env.AI);
  onTranscript(text: string, connection: Connection) {    console.log("User said:", text);  }}
```

### `onTranscript(text, connection)`

Called after each utterance is transcribed. Override this to process the transcript.

### Hooks

`withVoiceInput` supports the same lifecycle hooks as `withVoice`:

* `beforeCallStart(connection)` — return `false` to reject
* `onCallStart(connection)`, `onCallEnd(connection)`, `onInterrupt(connection)`
* `createTranscriber(connection)` — override for runtime model switching
* `afterTranscribe(transcript, connection)` — filter or transform transcripts

It does **not** have TTS hooks (`beforeSynthesize`, `afterSynthesize`) or `onTurn`.

## Client API: React hooks

### `useVoiceAgent`

Wraps `VoiceClient` for `withVoice` agents. Manages connection, mic capture, playback, silence detection, and interrupt detection.

```
import { useVoiceAgent } from "@cloudflare/voice/react";
const selectedSpeakerId = "default";
const {  status, // "idle" | "listening" | "thinking" | "speaking"  transcript, // TranscriptMessage[] — conversation history  interimTranscript, // string | null — real-time partial transcript  metrics, // VoicePipelineMetrics | null  audioLevel, // number (0–1) — current mic RMS level  isMuted, // boolean  connected, // boolean — WebSocket connected  error, // string | null  outputDeviceError, // string | null — non-fatal speaker routing issue  startCall, // () => Promise<void>  endCall, // () => void  toggleMute, // () => void  sendText, // (text: string) => void — bypass STT  sendJSON, // (data: Record<string, unknown>) => void  lastCustomMessage, // unknown — last non-voice message from server} = useVoiceAgent({  agent: "MyAgent",  name: "default",  host: window.location.host,  outputDeviceId: selectedSpeakerId, // Optional audiooutput device ID  enabled: true,});
```

Use `enabled: false` when the app must wait for async connection prerequisites, such as a user-scoped capability token. While disabled, the hook does not create or connect a `VoiceClient`, returns the idle disconnected state, and action callbacks such as `startCall()`, `sendText()`, and `sendJSON()` are safe no-ops.

When `enabled` changes to `true`, the hook connects with the current options. The first enable is treated as an initial connection, so `onReconnect` only fires for later connection identity changes while the hook remains enabled.

#### Output device selection

Pass `outputDeviceId` to route assistant playback to a selected speaker when the browser supports `HTMLMediaElement.setSinkId()`:

* [  JavaScript ](#tab-panel-5501)
* [  TypeScript ](#tab-panel-5502)

JavaScript

```
const [outputDeviceId, setOutputDeviceId] = useState("default");
const voice = useVoiceAgent({  agent: "MyAgent",  outputDeviceId,});
```

TypeScript

```
const [outputDeviceId, setOutputDeviceId] = useState("default");
const voice = useVoiceAgent({  agent: "MyAgent",  outputDeviceId,});
```

Use a `MediaDeviceInfo.deviceId` from `navigator.mediaDevices.enumerateDevices()` where `kind === "audiooutput"`. `"default"` and `undefined` use the system default output. Browsers without sink selection support continue playing through the default output and set `outputDeviceError` when a non-default output is requested. Device labels may be blank until the user grants microphone permission, so refresh device lists after `startCall()` if you show a speaker picker.

#### Tuning options

| Option             | Type    | Default | Description                                      |
| ------------------ | ------- | ------- | ------------------------------------------------ |
| enabled            | boolean | true    | Delay client creation and connection when false  |
| silenceThreshold   | number  | 0.04    | RMS below this is silence                        |
| silenceDurationMs  | number  | 500     | Silence duration before end\_of\_speech (ms)     |
| interruptThreshold | number  | 0.05    | RMS to detect speech during playback             |
| interruptChunks    | number  | 2       | Consecutive high-RMS chunks to trigger interrupt |

Changing tuning options triggers a client reconnect (the connection key includes them).

### `useVoiceInput`

Lightweight hook for dictation and voice-to-text. Accumulates user transcripts into a single string.

```
import { useVoiceInput } from "@cloudflare/voice/react";
function Dictation() {  const {    transcript, // string — accumulated text from all utterances    interimTranscript, // string | null — current partial transcript    isListening, // boolean    audioLevel, // number (0–1)    isMuted, // boolean    error, // string | null    start, // () => Promise<void>    stop, // () => void    toggleMute, // () => void    clear, // () => void — clear accumulated transcript  } = useVoiceInput({ agent: "DictationAgent" });
  return (    <div>      <textarea        value={transcript + (interimTranscript ? " " + interimTranscript : "")}        readOnly      />      <button onClick={isListening ? stop : start}>        {isListening ? "Stop" : "Dictate"}      </button>    </div>  );}
```

## Client API: `VoiceClient`

Framework-agnostic client for environments without React.

* [  JavaScript ](#tab-panel-5517)
* [  TypeScript ](#tab-panel-5518)

JavaScript

```
import { VoiceClient } from "@cloudflare/voice/client";
const client = new VoiceClient({ agent: "MyAgent" });
client.addEventListener("statuschange", (status) => {  console.log("Status:", status);});
client.addEventListener("transcriptchange", (messages) => {  console.log("Transcript:", messages);});
client.addEventListener("error", (err) => {  console.error("Error:", err);});
client.connect();await client.startCall();
// Switch assistant playback without reconnecting the call.await client.setOutputDevice(selectedSpeakerId);
// Later:client.endCall();client.disconnect();
```

TypeScript

```
import { VoiceClient } from "@cloudflare/voice/client";
const client = new VoiceClient({ agent: "MyAgent" });
client.addEventListener("statuschange", (status) => {  console.log("Status:", status);});
client.addEventListener("transcriptchange", (messages) => {  console.log("Transcript:", messages);});
client.addEventListener("error", (err) => {  console.error("Error:", err);});
client.connect();await client.startCall();
// Switch assistant playback without reconnecting the call.await client.setOutputDevice(selectedSpeakerId);
// Later:client.endCall();client.disconnect();
```

### Events

| Event             | Data type             | Description                           |
| ----------------- | --------------------- | ------------------------------------- |
| statuschange      | VoiceStatus           | Pipeline state changed                |
| transcriptchange  | TranscriptMessage\[\] | Transcript updated                    |
| interimtranscript | string \| null        | Interim transcript from streaming STT |
| metricschange     | VoicePipelineMetrics  | Pipeline timing metrics               |
| audiolevelchange  | number                | Mic audio level (0–1)                 |
| connectionchange  | boolean               | WebSocket connected/disconnected      |
| mutechange        | boolean               | Mute state changed                    |
| error             | string \| null        | Error occurred                        |
| outputdeviceerror | string \| null        | Non-fatal speaker routing issue       |
| custommessage     | unknown               | Non-voice message from server         |

### Advanced options

| Option          | Type             | Description                                           |
| --------------- | ---------------- | ----------------------------------------------------- |
| transport       | VoiceTransport   | Custom transport (default: WebSocket via PartySocket) |
| audioInput      | VoiceAudioInput  | Custom mic capture (default: built-in AudioWorklet)   |
| preferredFormat | VoiceAudioFormat | Hint for server audio format (advisory only)          |
| outputDeviceId  | string           | Preferred audiooutput device for assistant playback   |

## Providers

### Built-in (Workers AI)

No API keys required — use your Workers AI binding:

| Class             | Type           | Default model       | Recommended for |
| ----------------- | -------------- | ------------------- | --------------- |
| WorkersAIFluxSTT  | Continuous STT | @cf/deepgram/flux   | withVoice       |
| WorkersAINova3STT | Continuous STT | @cf/deepgram/nova-3 | withVoiceInput  |
| WorkersAITTS      | TTS            | @cf/deepgram/aura-1 | Both            |

* [  JavaScript ](#tab-panel-5519)
* [  TypeScript ](#tab-panel-5520)

JavaScript

```
import { Agent } from "agents";import {  withVoice,  WorkersAIFluxSTT,  WorkersAINova3STT,  WorkersAITTS,} from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
// Default usageexport class MyAgent extends VoiceAgent {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);}
// Custom optionsexport class CustomAgent extends VoiceAgent {  transcriber = new WorkersAIFluxSTT(this.env.AI, {    eotThreshold: 0.8,    keyterms: ["Cloudflare", "Workers"],  });  tts = new WorkersAITTS(this.env.AI, {    model: "@cf/deepgram/aura-1",    speaker: "asteria",  });}
```

TypeScript

```
import { Agent } from "agents";import {  withVoice,  WorkersAIFluxSTT,  WorkersAINova3STT,  WorkersAITTS,} from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
// Default usageexport class MyAgent extends VoiceAgent<Env> {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);}
// Custom optionsexport class CustomAgent extends VoiceAgent<Env> {  transcriber = new WorkersAIFluxSTT(this.env.AI, {    eotThreshold: 0.8,    keyterms: ["Cloudflare", "Workers"],  });  tts = new WorkersAITTS(this.env.AI, {    model: "@cf/deepgram/aura-1",    speaker: "asteria",  });}
```

### Third-party providers

| Package                      | Class         | Description             |
| ---------------------------- | ------------- | ----------------------- |
| @cloudflare/voice-deepgram   | DeepgramSTT   | Continuous STT          |
| @cloudflare/voice-elevenlabs | ElevenLabsTTS | High-quality TTS        |
| @cloudflare/voice-twilio     | TwilioAdapter | Telephony (phone calls) |

**ElevenLabs TTS:**

* [  JavaScript ](#tab-panel-5507)
* [  TypeScript ](#tab-panel-5508)

JavaScript

```
import { ElevenLabsTTS } from "@cloudflare/voice-elevenlabs";
export class MyAgent extends VoiceAgent {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new ElevenLabsTTS({    apiKey: this.env.ELEVENLABS_API_KEY,    voiceId: "21m00Tcm4TlvDq8ikWAM",  });}
```

TypeScript

```
import { ElevenLabsTTS } from "@cloudflare/voice-elevenlabs";
export class MyAgent extends VoiceAgent<Env> {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new ElevenLabsTTS({    apiKey: this.env.ELEVENLABS_API_KEY,    voiceId: "21m00Tcm4TlvDq8ikWAM",  });}
```

**Deepgram STT:**

* [  JavaScript ](#tab-panel-5509)
* [  TypeScript ](#tab-panel-5510)

JavaScript

```
import { DeepgramSTT } from "@cloudflare/voice-deepgram";
export class MyAgent extends VoiceAgent {  transcriber = new DeepgramSTT({    apiKey: this.env.DEEPGRAM_API_KEY,  });  tts = new WorkersAITTS(this.env.AI);}
```

TypeScript

```
import { DeepgramSTT } from "@cloudflare/voice-deepgram";
export class MyAgent extends VoiceAgent<Env> {  transcriber = new DeepgramSTT({    apiKey: this.env.DEEPGRAM_API_KEY,  });  tts = new WorkersAITTS(this.env.AI);}
```

## Telephony (Twilio)

Connect phone calls to your voice agent using the Twilio adapter:

Terminal window

```
npm install @cloudflare/voice-twilio
```

The adapter bridges Twilio Media Streams to your VoiceAgent:

```
Phone → Twilio → WebSocket → TwilioAdapter → WebSocket → VoiceAgent
```

`WorkersAITTS` returns MP3, which cannot be decoded to PCM in the Workers runtime. When using the Twilio adapter, use a TTS provider that outputs raw PCM (for example, ElevenLabs with `outputFormat: "pcm_16000"`).

## Text messages

`withVoice` agents can also receive text messages, bypassing STT entirely. This is useful for chat-style input alongside voice.

```
const { sendText } = useVoiceAgent({ agent: "MyAgent" });
// Send text — goes straight to onTurn() without STTsendText("What is the weather like today?");
```

Text messages work both during and outside of active calls. During a call, the response is spoken aloud via TTS. Outside a call, the response is sent as text-only transcript messages.

## Custom messages

Send and receive application-level JSON messages alongside voice protocol messages. Non-voice messages pass through to your `onMessage` handler on the server and emit `custommessage` events on the client.

**Server:**

* [  JavaScript ](#tab-panel-5515)
* [  TypeScript ](#tab-panel-5516)

JavaScript

```
export class MyAgent extends VoiceAgent {  onMessage(connection, message) {    const data = JSON.parse(message);    if (data.type === "kick_speaker") {      this.forceEndCall(connection);    }  }}
```

TypeScript

```
export class MyAgent extends VoiceAgent<Env> {  onMessage(connection: Connection, message: WSMessage) {    const data = JSON.parse(message as string);    if (data.type === "kick_speaker") {      this.forceEndCall(connection);    }  }}
```

**Client:**

```
const { sendJSON, lastCustomMessage } = useVoiceAgent({ agent: "MyAgent" });
sendJSON({ type: "kick_speaker" });
useEffect(() => {  if (lastCustomMessage) {    console.log("Custom message:", lastCustomMessage);  }}, [lastCustomMessage]);
```

## Single-speaker enforcement

Use `beforeCallStart` to restrict who can start a call. This example enforces single-speaker — only one connection can be the active speaker at a time:

* [  JavaScript ](#tab-panel-5521)
* [  TypeScript ](#tab-panel-5522)

JavaScript

```
import {} from "agents";
export class MyAgent extends VoiceAgent {  #speakerId = null;
  beforeCallStart(connection) {    if (this.#speakerId !== null) {      return false;    }    this.#speakerId = connection.id;    return true;  }
  onCallEnd(connection) {    if (this.#speakerId === connection.id) {      this.#speakerId = null;    }  }}
```

TypeScript

```
import { type Connection } from "agents";
export class MyAgent extends VoiceAgent<Env> {  #speakerId: string | null = null;
  beforeCallStart(connection: Connection) {    if (this.#speakerId !== null) {      return false;    }    this.#speakerId = connection.id;    return true;  }
  onCallEnd(connection: Connection) {    if (this.#speakerId === connection.id) {      this.#speakerId = null;    }  }}
```

## Pipeline metrics

`withVoice` agents emit timing metrics after each turn:

```
const { metrics } = useVoiceAgent({ agent: "MyAgent" });
// metrics: {//   llm_ms: 850,//   tts_ms: 200,//   first_audio_ms: 950,//   total_ms: 1200,// }
```

## Conversation history

`withVoice` automatically persists conversation messages to SQLite. Access history in your `onTurn` via `context.messages`, or directly:

* [  JavaScript ](#tab-panel-5511)
* [  TypeScript ](#tab-panel-5512)

JavaScript

```
const history = this.getConversationHistory(20);
this.saveMessage("assistant", "Welcome! How can I help?");
```

TypeScript

```
const history = this.getConversationHistory(20);
this.saveMessage("assistant", "Welcome! How can I help?");
```

History survives Durable Object restarts and client reconnections. Voice agents use `keepAlive` to prevent eviction during active calls.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/communication-channels/voice/#page","headline":"Voice · Cloudflare Agents docs","description":"Build real-time voice agents with speech-to-text, text-to-speech, and conversation persistence over WebSocket.","url":"https://developers.cloudflare.com/agents/communication-channels/voice/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-16","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/communication-channels/","name":"Communication channels"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/communication-channels/voice/","name":"Voice"}}]}
```

---

---
title: Webhooks
description: Receive and route webhook events from external services to dedicated Cloudflare Agent instances.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Webhooks

Receive webhook events from external services and route them to dedicated agent instances. Each webhook source (repository, customer, device) can have its own agent with isolated state, persistent storage, and real-time client connections.

## Quick start

* [  JavaScript ](#tab-panel-5553)
* [  TypeScript ](#tab-panel-5554)

JavaScript

```
import { Agent, getAgentByName, routeAgentRequest } from "agents";
// Agent that handles webhooks for a specific entityexport class WebhookAgent extends Agent {  async onRequest(request) {    if (request.method !== "POST") {      return new Response("Method not allowed", { status: 405 });    }
    // Verify the webhook signature    const signature = request.headers.get("X-Hub-Signature-256");    const body = await request.text();
    if (      !(await this.verifySignature(body, signature, this.env.WEBHOOK_SECRET))    ) {      return new Response("Invalid signature", { status: 401 });    }
    // Process the webhook payload    const payload = JSON.parse(body);    await this.processEvent(payload);
    return new Response("OK", { status: 200 });  }
  async verifySignature(payload, signature, secret) {    if (!signature) return false;
    const encoder = new TextEncoder();    const key = await crypto.subtle.importKey(      "raw",      encoder.encode(secret),      { name: "HMAC", hash: "SHA-256" },      false,      ["sign"],    );
    const signatureBytes = await crypto.subtle.sign(      "HMAC",      key,      encoder.encode(payload),    );    const expected = `sha256=${Array.from(new Uint8Array(signatureBytes))      .map((b) => b.toString(16).padStart(2, "0"))      .join("")}`;
    return signature === expected;  }
  async processEvent(payload) {    // Store event, update state, trigger actions...  }}
// Route webhooks to the right agent instanceexport default {  async fetch(request, env) {    const url = new URL(request.url);
    // Webhook endpoint: POST /webhooks/:entityId    if (url.pathname.startsWith("/webhooks/") && request.method === "POST") {      const entityId = url.pathname.split("/")[2];      const agent = await getAgentByName(env.WebhookAgent, entityId);      return agent.fetch(request);    }
    // Default routing for WebSocket connections    return (      (await routeAgentRequest(request, env)) ||      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
import { Agent, getAgentByName, routeAgentRequest } from "agents";
// Agent that handles webhooks for a specific entityexport class WebhookAgent extends Agent {  async onRequest(request: Request): Promise<Response> {    if (request.method !== "POST") {      return new Response("Method not allowed", { status: 405 });    }
    // Verify the webhook signature    const signature = request.headers.get("X-Hub-Signature-256");    const body = await request.text();
    if (      !(await this.verifySignature(body, signature, this.env.WEBHOOK_SECRET))    ) {      return new Response("Invalid signature", { status: 401 });    }
    // Process the webhook payload    const payload = JSON.parse(body);    await this.processEvent(payload);
    return new Response("OK", { status: 200 });  }
  private async verifySignature(    payload: string,    signature: string | null,    secret: string,  ): Promise<boolean> {    if (!signature) return false;
    const encoder = new TextEncoder();    const key = await crypto.subtle.importKey(      "raw",      encoder.encode(secret),      { name: "HMAC", hash: "SHA-256" },      false,      ["sign"],    );
    const signatureBytes = await crypto.subtle.sign(      "HMAC",      key,      encoder.encode(payload),    );    const expected = `sha256=${Array.from(new Uint8Array(signatureBytes))      .map((b) => b.toString(16).padStart(2, "0"))      .join("")}`;
    return signature === expected;  }
  private async processEvent(payload: unknown) {    // Store event, update state, trigger actions...  }}
// Route webhooks to the right agent instanceexport default {  async fetch(request: Request, env: Env): Promise<Response> {    const url = new URL(request.url);
    // Webhook endpoint: POST /webhooks/:entityId    if (url.pathname.startsWith("/webhooks/") && request.method === "POST") {      const entityId = url.pathname.split("/")[2];      const agent = await getAgentByName(env.WebhookAgent, entityId);      return agent.fetch(request);    }
    // Default routing for WebSocket connections    return (      (await routeAgentRequest(request, env)) ||      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

## Use cases

Webhooks combined with agents enable patterns where each external entity gets its own isolated, stateful agent instance.

### Developer tools

| Use case                 | Description                                                                |
| ------------------------ | -------------------------------------------------------------------------- |
| **GitHub Repo Monitor**  | One agent per repository tracking commits, PRs, issues, and stars          |
| **CI/CD Pipeline Agent** | React to build/deploy events, notify on failures, track deployment history |
| **Linear/Jira Tracker**  | Auto-triage issues, assign based on content, track resolution times        |

### E-commerce and payments

| Use case                   | Description                                                           |
| -------------------------- | --------------------------------------------------------------------- |
| **Stripe Customer Agent**  | One agent per customer tracking payments, subscriptions, and disputes |
| **Shopify Order Agent**    | Order lifecycle from creation to fulfillment with inventory sync      |
| **Payment Reconciliation** | Match webhook events to internal records, flag discrepancies          |

### Communication and notifications

| Use case             | Description                                                             |
| -------------------- | ----------------------------------------------------------------------- |
| **Twilio SMS/Voice** | Conversational agents triggered by inbound messages or calls            |
| **Slack Bot**        | Respond to slash commands, button clicks, and interactive messages      |
| **Email Tracking**   | SendGrid/Mailgun delivery events, bounce handling, engagement analytics |

### IoT and infrastructure

| Use case              | Description                                                  |
| --------------------- | ------------------------------------------------------------ |
| **Device Telemetry**  | One agent per device processing sensor data streams          |
| **Alert Aggregation** | Collect alerts from PagerDuty, Datadog, or custom monitoring |
| **Home Automation**   | React to IFTTT/Zapier triggers with persistent state         |

### SaaS integrations

| Use case             | Description                                                     |
| -------------------- | --------------------------------------------------------------- |
| **CRM Sync**         | Salesforce/HubSpot contact and deal updates                     |
| **Calendar Agent**   | Google Calendar event notifications and scheduling              |
| **Form Submissions** | Typeform, Tally, or custom form webhooks with follow-up actions |

## Routing webhooks to agents

The key pattern is extracting an entity identifier from the webhook and using `getAgentByName()` to route to a dedicated agent instance.

### Extract entity from payload

Most webhooks include an identifier in the payload:

* [  JavaScript ](#tab-panel-5527)
* [  TypeScript ](#tab-panel-5528)

JavaScript

```
export default {  async fetch(request, env) {    if (request.method === "POST" && url.pathname === "/webhooks/github") {      const payload = await request.clone().json();
      // Extract entity ID from payload      const repoFullName = payload.repository?.full_name;      if (!repoFullName) {        return new Response("Missing repository", { status: 400 });      }
      // Sanitize for use as agent name      const agentName = repoFullName.toLowerCase().replace(/\//g, "-");
      // Route to dedicated agent      const agent = await getAgentByName(env.RepoAgent, agentName);      return agent.fetch(request);    }  },};
```

TypeScript

```
export default {  async fetch(request: Request, env: Env): Promise<Response> {    if (request.method === "POST" && url.pathname === "/webhooks/github") {      const payload = await request.clone().json();
      // Extract entity ID from payload      const repoFullName = payload.repository?.full_name;      if (!repoFullName) {        return new Response("Missing repository", { status: 400 });      }
      // Sanitize for use as agent name      const agentName = repoFullName.toLowerCase().replace(/\//g, "-");
      // Route to dedicated agent      const agent = await getAgentByName(env.RepoAgent, agentName);      return agent.fetch(request);    }  },} satisfies ExportedHandler<Env>;
```

### Extract entity from URL

Alternatively, include the entity ID in the webhook URL:

* [  JavaScript ](#tab-panel-5523)
* [  TypeScript ](#tab-panel-5524)

JavaScript

```
// Webhook URL: https://your-worker.dev/webhooks/stripe/cus_123456if (url.pathname.startsWith("/webhooks/stripe/")) {  const customerId = url.pathname.split("/")[3]; // "cus_123456"  const agent = await getAgentByName(env.StripeAgent, customerId);  return agent.fetch(request);}
```

TypeScript

```
// Webhook URL: https://your-worker.dev/webhooks/stripe/cus_123456if (url.pathname.startsWith("/webhooks/stripe/")) {  const customerId = url.pathname.split("/")[3]; // "cus_123456"  const agent = await getAgentByName(env.StripeAgent, customerId);  return agent.fetch(request);}
```

### Extract entity from headers

Some services include identifiers in headers:

* [  JavaScript ](#tab-panel-5525)
* [  TypeScript ](#tab-panel-5526)

JavaScript

```
// Slack sends workspace info in headersconst teamId = request.headers.get("X-Slack-Team-Id");if (teamId) {  const agent = await getAgentByName(env.SlackAgent, teamId);  return agent.fetch(request);}
```

TypeScript

```
// Slack sends workspace info in headersconst teamId = request.headers.get("X-Slack-Team-Id");if (teamId) {  const agent = await getAgentByName(env.SlackAgent, teamId);  return agent.fetch(request);}
```

## Signature verification

Always verify webhook signatures to ensure requests are authentic. Most providers use HMAC-SHA256.

### HMAC-SHA256 pattern

* [  JavaScript ](#tab-panel-5539)
* [  TypeScript ](#tab-panel-5540)

JavaScript

```
async function verifySignature(payload, signature, secret) {  if (!signature) return false;
  const encoder = new TextEncoder();  const key = await crypto.subtle.importKey(    "raw",    encoder.encode(secret),    { name: "HMAC", hash: "SHA-256" },    false,    ["sign"],  );
  const signatureBytes = await crypto.subtle.sign(    "HMAC",    key,    encoder.encode(payload),  );
  const expected = `sha256=${Array.from(new Uint8Array(signatureBytes))    .map((b) => b.toString(16).padStart(2, "0"))    .join("")}`;
  // Use timing-safe comparison in production  return signature === expected;}
```

TypeScript

```
async function verifySignature(  payload: string,  signature: string | null,  secret: string,): Promise<boolean> {  if (!signature) return false;
  const encoder = new TextEncoder();  const key = await crypto.subtle.importKey(    "raw",    encoder.encode(secret),    { name: "HMAC", hash: "SHA-256" },    false,    ["sign"],  );
  const signatureBytes = await crypto.subtle.sign(    "HMAC",    key,    encoder.encode(payload),  );
  const expected = `sha256=${Array.from(new Uint8Array(signatureBytes))    .map((b) => b.toString(16).padStart(2, "0"))    .join("")}`;
  // Use timing-safe comparison in production  return signature === expected;}
```

### Provider-specific headers

| Provider | Signature Header      | Algorithm                    |
| -------- | --------------------- | ---------------------------- |
| GitHub   | X-Hub-Signature-256   | HMAC-SHA256                  |
| Stripe   | Stripe-Signature      | HMAC-SHA256 (with timestamp) |
| Twilio   | X-Twilio-Signature    | HMAC-SHA1                    |
| Slack    | X-Slack-Signature     | HMAC-SHA256 (with timestamp) |
| Shopify  | X-Shopify-Hmac-Sha256 | HMAC-SHA256 (base64)         |

## Processing webhooks

### The onRequest handler

Use `onRequest()` to handle incoming webhooks in your agent:

* [  JavaScript ](#tab-panel-5547)
* [  TypeScript ](#tab-panel-5548)

JavaScript

```
export class WebhookAgent extends Agent {  async onRequest(request) {    // 1. Validate method    if (request.method !== "POST") {      return new Response("Method not allowed", { status: 405 });    }
    // 2. Get event type from headers    const eventType = request.headers.get("X-Event-Type");
    // 3. Verify signature    const signature = request.headers.get("X-Signature");    const body = await request.text();
    if (!(await this.verifySignature(body, signature))) {      return new Response("Invalid signature", { status: 401 });    }
    // 4. Parse and process    const payload = JSON.parse(body);    await this.handleEvent(eventType, payload);
    // 5. Respond quickly    return new Response("OK", { status: 200 });  }
  async handleEvent(type, payload) {    // Update state (broadcasts to connected clients)    this.setState({      ...this.state,      lastEventType: type,      lastEventTime: new Date().toISOString(),    });
    // Store in SQL for history    this      .sql`INSERT INTO events (type, payload, timestamp) VALUES (${type}, ${JSON.stringify(payload)}, ${Date.now()})`;  }}
```

TypeScript

```
export class WebhookAgent extends Agent {  async onRequest(request: Request): Promise<Response> {    // 1. Validate method    if (request.method !== "POST") {      return new Response("Method not allowed", { status: 405 });    }
    // 2. Get event type from headers    const eventType = request.headers.get("X-Event-Type");
    // 3. Verify signature    const signature = request.headers.get("X-Signature");    const body = await request.text();
    if (!(await this.verifySignature(body, signature))) {      return new Response("Invalid signature", { status: 401 });    }
    // 4. Parse and process    const payload = JSON.parse(body);    await this.handleEvent(eventType, payload);
    // 5. Respond quickly    return new Response("OK", { status: 200 });  }
  private async handleEvent(type: string, payload: unknown) {    // Update state (broadcasts to connected clients)    this.setState({      ...this.state,      lastEventType: type,      lastEventTime: new Date().toISOString(),    });
    // Store in SQL for history    this      .sql`INSERT INTO events (type, payload, timestamp) VALUES (${type}, ${JSON.stringify(payload)}, ${Date.now()})`;  }}
```

## Storing webhook events

Use SQLite to persist webhook events for history and replay.

### Event table schema

* [  JavaScript ](#tab-panel-5535)
* [  TypeScript ](#tab-panel-5536)

JavaScript

```
class WebhookAgent extends Agent {  async onStart() {    this.sql`      CREATE TABLE IF NOT EXISTS events (        id TEXT PRIMARY KEY,        type TEXT NOT NULL,        action TEXT,        title TEXT NOT NULL,        description TEXT,        url TEXT,        actor TEXT,        payload TEXT,        timestamp TEXT NOT NULL      )    `;
    this.sql`      CREATE INDEX IF NOT EXISTS idx_events_timestamp      ON events(timestamp DESC)    `;  }}
```

TypeScript

```
class WebhookAgent extends Agent {  async onStart(): Promise<void> {    this.sql`      CREATE TABLE IF NOT EXISTS events (        id TEXT PRIMARY KEY,        type TEXT NOT NULL,        action TEXT,        title TEXT NOT NULL,        description TEXT,        url TEXT,        actor TEXT,        payload TEXT,        timestamp TEXT NOT NULL      )    `;
    this.sql`      CREATE INDEX IF NOT EXISTS idx_events_timestamp      ON events(timestamp DESC)    `;  }}
```

### Cleanup old events

Prevent unbounded growth by keeping only recent events:

* [  JavaScript ](#tab-panel-5529)
* [  TypeScript ](#tab-panel-5530)

JavaScript

```
// Keep last 100 eventsthis.sql`  DELETE FROM events WHERE id NOT IN (    SELECT id FROM events ORDER BY timestamp DESC LIMIT 100  )`;
// Or delete events older than 30 daysthis.sql`  DELETE FROM events  WHERE timestamp < datetime('now', '-30 days')`;
```

TypeScript

```
// Keep last 100 eventsthis.sql`  DELETE FROM events WHERE id NOT IN (    SELECT id FROM events ORDER BY timestamp DESC LIMIT 100  )`;
// Or delete events older than 30 daysthis.sql`  DELETE FROM events  WHERE timestamp < datetime('now', '-30 days')`;
```

### Query events

* [  JavaScript ](#tab-panel-5543)
* [  TypeScript ](#tab-panel-5544)

JavaScript

```
import { Agent, callable } from "agents";
class WebhookAgent extends Agent {  @callable()  getEvents(limit = 20) {    return [      ...this.sql`      SELECT * FROM events      ORDER BY timestamp DESC      LIMIT ${limit}    `,    ];  }
  @callable()  getEventsByType(type, limit = 20) {    return [      ...this.sql`      SELECT * FROM events      WHERE type = ${type}      ORDER BY timestamp DESC      LIMIT ${limit}    `,    ];  }}
```

TypeScript

```
import { Agent, callable } from "agents";
class WebhookAgent extends Agent {  @callable()  getEvents(limit = 20) {    return [      ...this.sql`      SELECT * FROM events      ORDER BY timestamp DESC      LIMIT ${limit}    `,    ];  }
  @callable()  getEventsByType(type: string, limit = 20) {    return [      ...this.sql`      SELECT * FROM events      WHERE type = ${type}      ORDER BY timestamp DESC      LIMIT ${limit}    `,    ];  }}
```

## Real-time broadcasting

When a webhook arrives, update agent state to automatically broadcast to connected WebSocket clients.

* [  JavaScript ](#tab-panel-5531)
* [  TypeScript ](#tab-panel-5532)

JavaScript

```
class WebhookAgent extends Agent {  async processWebhook(eventType, payload) {    // Update state - this automatically broadcasts to all connected clients    this.setState({      ...this.state,      stats: payload.stats,      lastEvent: {        type: eventType,        timestamp: new Date().toISOString(),      },    });  }}
```

TypeScript

```
class WebhookAgent extends Agent {  private async processWebhook(eventType: string, payload: WebhookPayload) {    // Update state - this automatically broadcasts to all connected clients    this.setState({      ...this.state,      stats: payload.stats,      lastEvent: {        type: eventType,        timestamp: new Date().toISOString(),      },    });  }}
```

On the client side:

```
import { useAgent } from "agents/react";
function Dashboard() {  const [state, setState] = useState(null);
  const agent = useAgent({    agent: "webhook-agent",    name: "my-entity-id",    onStateUpdate: (newState) => {      setState(newState); // Automatically updates when webhooks arrive    },  });
  return <div>Last event: {state?.lastEvent?.type}</div>;}
```

## Patterns

### Event deduplication

Prevent processing duplicate events using event IDs:

* [  JavaScript ](#tab-panel-5541)
* [  TypeScript ](#tab-panel-5542)

JavaScript

```
class WebhookAgent extends Agent {  async handleEvent(eventId, payload) {    // Check if already processed    const existing = [      ...this.sql`      SELECT id FROM events WHERE id = ${eventId}    `,    ];
    if (existing.length > 0) {      console.log(`Event ${eventId} already processed, skipping`);      return;    }
    // Process and store    await this.processPayload(payload);    this.sql`INSERT INTO events (id, ...) VALUES (${eventId}, ...)`;  }}
```

TypeScript

```
class WebhookAgent extends Agent {  async handleEvent(eventId: string, payload: unknown) {    // Check if already processed    const existing = [      ...this.sql`      SELECT id FROM events WHERE id = ${eventId}    `,    ];
    if (existing.length > 0) {      console.log(`Event ${eventId} already processed, skipping`);      return;    }
    // Process and store    await this.processPayload(payload);    this.sql`INSERT INTO events (id, ...) VALUES (${eventId}, ...)`;  }}
```

### Respond quickly, process asynchronously

Webhook providers expect fast responses. Use the queue for heavy processing:

* [  JavaScript ](#tab-panel-5545)
* [  TypeScript ](#tab-panel-5546)

JavaScript

```
class WebhookAgent extends Agent {  async onRequest(request) {    const payload = await request.json();
    // Quick validation    if (!this.isValid(payload)) {      return new Response("Invalid", { status: 400 });    }
    // Queue heavy processing    await this.queue("processWebhook", payload);
    // Respond immediately    return new Response("Accepted", { status: 202 });  }
  async processWebhook(payload) {    // Heavy processing happens here, after response sent    await this.enrichData(payload);    await this.notifyDownstream(payload);    await this.updateAnalytics(payload);  }}
```

TypeScript

```
class WebhookAgent extends Agent {  async onRequest(request: Request): Promise<Response> {    const payload = await request.json();
    // Quick validation    if (!this.isValid(payload)) {      return new Response("Invalid", { status: 400 });    }
    // Queue heavy processing    await this.queue("processWebhook", payload);
    // Respond immediately    return new Response("Accepted", { status: 202 });  }
  async processWebhook(payload: WebhookPayload) {    // Heavy processing happens here, after response sent    await this.enrichData(payload);    await this.notifyDownstream(payload);    await this.updateAnalytics(payload);  }}
```

If the asynchronous work is a single Think chat turn, use `submitMessages()` instead. It returns a durable submission ID immediately and lets retries use an idempotency key instead of duplicating the message turn:

* [  JavaScript ](#tab-panel-5533)
* [  TypeScript ](#tab-panel-5534)

JavaScript

```
const submission = await this.submitMessages(messages, {  idempotencyKey: payload.id,});
return Response.json(  { submissionId: submission.submissionId },  { status: 202 },);
```

TypeScript

```
const submission = await this.submitMessages(messages, {  idempotencyKey: payload.id,});
return Response.json(  { submissionId: submission.submissionId },  { status: 202 },);
```

If the webhook owns application side effects around a turn, such as restoring a provider thread and posting a visible reply, use [startFiber()](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/#startfiber) around that job. Managed fibers retain status, dedupe provider retries, and let `onFiberRecovered()` or `resolveFiber()` record the app-level recovery outcome.

### Multi-provider routing

Handle webhooks from multiple services in one Worker:

* [  JavaScript ](#tab-panel-5551)
* [  TypeScript ](#tab-panel-5552)

JavaScript

```
export default {  async fetch(request, env) {    const url = new URL(request.url);
    if (request.method === "POST") {      // GitHub webhooks      if (url.pathname.startsWith("/webhooks/github/")) {        const payload = await request.clone().json();        const repoName = payload.repository?.full_name?.replace("/", "-");        const agent = await getAgentByName(env.GitHubAgent, repoName);        return agent.fetch(request);      }
      // Stripe webhooks      if (url.pathname.startsWith("/webhooks/stripe/")) {        const payload = await request.clone().json();        const customerId = payload.data?.object?.customer;        const agent = await getAgentByName(env.StripeAgent, customerId);        return agent.fetch(request);      }
      // Slack webhooks      if (url.pathname === "/webhooks/slack") {        const teamId = request.headers.get("X-Slack-Team-Id");        const agent = await getAgentByName(env.SlackAgent, teamId);        return agent.fetch(request);      }    }
    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
export default {  async fetch(request: Request, env: Env): Promise<Response> {    const url = new URL(request.url);
    if (request.method === "POST") {      // GitHub webhooks      if (url.pathname.startsWith("/webhooks/github/")) {        const payload = await request.clone().json();        const repoName = payload.repository?.full_name?.replace("/", "-");        const agent = await getAgentByName(env.GitHubAgent, repoName);        return agent.fetch(request);      }
      // Stripe webhooks      if (url.pathname.startsWith("/webhooks/stripe/")) {        const payload = await request.clone().json();        const customerId = payload.data?.object?.customer;        const agent = await getAgentByName(env.StripeAgent, customerId);        return agent.fetch(request);      }
      // Slack webhooks      if (url.pathname === "/webhooks/slack") {        const teamId = request.headers.get("X-Slack-Team-Id");        const agent = await getAgentByName(env.SlackAgent, teamId);        return agent.fetch(request);      }    }
    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

## Sending outgoing webhooks

Agents can also send webhooks to external services:

* [  JavaScript ](#tab-panel-5549)
* [  TypeScript ](#tab-panel-5550)

JavaScript

```
export class NotificationAgent extends Agent {  async notifySlack(message) {    const response = await fetch(this.env.SLACK_WEBHOOK_URL, {      method: "POST",      headers: { "Content-Type": "application/json" },      body: JSON.stringify({ text: message }),    });
    if (!response.ok) {      throw new Error(`Slack notification failed: ${response.status}`);    }  }
  async sendSignedWebhook(url, payload) {    const body = JSON.stringify(payload);    const signature = await this.sign(body, this.env.WEBHOOK_SECRET);
    await fetch(url, {      method: "POST",      headers: {        "Content-Type": "application/json",        "X-Signature": signature,      },      body,    });  }}
```

TypeScript

```
export class NotificationAgent extends Agent {  async notifySlack(message: string) {    const response = await fetch(this.env.SLACK_WEBHOOK_URL, {      method: "POST",      headers: { "Content-Type": "application/json" },      body: JSON.stringify({ text: message }),    });
    if (!response.ok) {      throw new Error(`Slack notification failed: ${response.status}`);    }  }
  async sendSignedWebhook(url: string, payload: unknown) {    const body = JSON.stringify(payload);    const signature = await this.sign(body, this.env.WEBHOOK_SECRET);
    await fetch(url, {      method: "POST",      headers: {        "Content-Type": "application/json",        "X-Signature": signature,      },      body,    });  }}
```

## Security best practices

1. **Always verify signatures** \- Never trust unverified webhooks.
2. **Use environment secrets** \- Store secrets with `wrangler secret put`, not in code.
3. **Respond quickly** \- Return 200/202 within seconds to avoid retries.
4. **Validate payloads** \- Check required fields before processing.
5. **Log rejections** \- Track invalid signatures for security monitoring.
6. **Use HTTPS** \- Webhook URLs should always use TLS.

* [  JavaScript ](#tab-panel-5537)
* [  TypeScript ](#tab-panel-5538)

JavaScript

```
// Store secrets securely// wrangler secret put GITHUB_WEBHOOK_SECRET
// Access in agentconst secret = this.env.GITHUB_WEBHOOK_SECRET;
```

TypeScript

```
// Store secrets securely// wrangler secret put GITHUB_WEBHOOK_SECRET
// Access in agentconst secret = this.env.GITHUB_WEBHOOK_SECRET;
```

## Common webhook providers

| Provider | Documentation                                                                                                  |
| -------- | -------------------------------------------------------------------------------------------------------------- |
| GitHub   | [Webhook events and payloads ↗](https://docs.github.com/en/webhooks)                                           |
| Stripe   | [Webhook signatures ↗](https://stripe.com/docs/webhooks/signatures)                                            |
| Twilio   | [Validate webhook requests ↗](https://www.twilio.com/docs/usage/webhooks/webhooks-security)                    |
| Slack    | [Verifying requests ↗](https://api.slack.com/authentication/verifying-requests-from-slack)                     |
| Shopify  | [Webhook verification ↗](https://shopify.dev/docs/apps/webhooks/configuration/https#step-5-verify-the-webhook) |
| SendGrid | [Event webhook ↗](https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook)      |
| Linear   | [Webhooks ↗](https://developers.linear.app/docs/graphql/webhooks)                                              |

## Next steps

[ Queue tasks ](https://developers.cloudflare.com/agents/runtime/execution/queue-tasks/) Background task processing. 

[ Email routing ](https://developers.cloudflare.com/agents/communication-channels/email/) Handle inbound emails in your agent. 

[ Agents API ](https://developers.cloudflare.com/agents/runtime/agents-api/) Complete API reference for the Agents SDK.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/communication-channels/webhooks/#page","headline":"Webhooks · Cloudflare Agents docs","description":"Receive and route webhook events from external services to dedicated Cloudflare Agent instances.","url":"https://developers.cloudflare.com/agents/communication-channels/webhooks/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/communication-channels/","name":"Communication channels"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/communication-channels/webhooks/","name":"Webhooks"}}]}
```

---

---
title: Push notifications
description: Send browser push notifications from a Cloudflare Agent, even when the user has closed the tab.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Push notifications

Send browser push notifications from your agent — even when the user has closed the tab. By combining the agent's persistent state (for storing push subscriptions), scheduling (for timed delivery), and the [Web Push API ↗](https://developer.mozilla.org/en-US/docs/Web/API/Push%5FAPI), you can reach users who are completely offline.

## How it works

```
Browser                              Agent (Durable Object)───────                              ──────────────────────1. Register service worker2. Subscribe to push (VAPID key)3. Send subscription to agent ──────► Store in this.state4. Create reminder ─────────────────► this.schedule(delay, "sendReminder", payload)
   ... user closes tab ...
5.                                    Alarm fires → sendReminder()                                      web-push sends encrypted payload                                              │6. Service worker receives push ◄─────────────┘7. showNotification()
```

The agent stores push subscriptions durably in its state and uses `this.schedule()` to fire notifications at the right time. When the alarm triggers, the agent calls the push service endpoint using the [web-push ↗](https://www.npmjs.com/package/web-push) library. The browser's service worker receives the push event and displays a native notification.

## Prerequisites

### Generate VAPID keys

Web Push requires a VAPID (Voluntary Application Server Identification) key pair. Generate one:

Terminal window

```
npx web-push generate-vapid-keys
```

Store the keys in a `.env` file for local development:

```
VAPID_PUBLIC_KEY=BGxK...VAPID_PRIVATE_KEY=abc1...VAPID_SUBJECT=mailto:you@example.com
```

For production, use `wrangler secret put`:

Terminal window

```
wrangler secret put VAPID_PUBLIC_KEYwrangler secret put VAPID_PRIVATE_KEYwrangler secret put VAPID_SUBJECT
```

## Create the agent

The agent has three responsibilities: store push subscriptions, schedule reminders, and send notifications when alarms fire.

* [  JavaScript ](#tab-panel-5563)
* [  TypeScript ](#tab-panel-5564)

JavaScript

```
import { Agent, callable, routeAgentRequest } from "agents";import webpush from "web-push";
export class ReminderAgent extends Agent {  initialState = {    subscriptions: [],    reminders: [],  };
  @callable()  getVapidPublicKey() {    return this.env.VAPID_PUBLIC_KEY;  }
  @callable()  async subscribe(subscription) {    const exists = this.state.subscriptions.some(      (s) => s.endpoint === subscription.endpoint,    );    if (!exists) {      this.setState({        ...this.state,        subscriptions: [...this.state.subscriptions, subscription],      });    }    return { ok: true };  }
  @callable()  async unsubscribe(endpoint) {    this.setState({      ...this.state,      subscriptions: this.state.subscriptions.filter(        (s) => s.endpoint !== endpoint,      ),    });    return { ok: true };  }
  @callable()  async createReminder(message, delaySeconds) {    const id = crypto.randomUUID();    const scheduledAt = Date.now() + delaySeconds * 1000;    const reminder = { id, message, scheduledAt, sent: false };
    this.setState({      ...this.state,      reminders: [...this.state.reminders, reminder],    });
    await this.schedule(delaySeconds, "sendReminder", { id, message });    return reminder;  }
  async sendReminder(payload) {    webpush.setVapidDetails(      this.env.VAPID_SUBJECT,      this.env.VAPID_PUBLIC_KEY,      this.env.VAPID_PRIVATE_KEY,    );
    const deadEndpoints = [];
    await Promise.all(      this.state.subscriptions.map(async (sub) => {        try {          await webpush.sendNotification(            sub,            JSON.stringify({              title: "Reminder",              body: payload.message,              tag: `reminder-${payload.id}`,            }),          );        } catch (err) {          const statusCode =            err instanceof webpush.WebPushError ? err.statusCode : 0;          if (statusCode === 404 || statusCode === 410) {            deadEndpoints.push(sub.endpoint);          }        }      }),    );
    if (deadEndpoints.length > 0) {      this.setState({        ...this.state,        subscriptions: this.state.subscriptions.filter(          (s) => !deadEndpoints.includes(s.endpoint),        ),      });    }
    this.setState({      ...this.state,      reminders: this.state.reminders.map((r) =>        r.id === payload.id ? { ...r, sent: true } : r,      ),    });
    this.broadcast(      JSON.stringify({        type: "reminder_sent",        id: payload.id,        timestamp: Date.now(),      }),    );  }}
export default {  async fetch(request, env) {    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
import { Agent, callable, routeAgentRequest } from "agents";import webpush from "web-push";
type Subscription = {  endpoint: string;  expirationTime: number | null;  keys: {    p256dh: string;    auth: string;  };};
type Reminder = {  id: string;  message: string;  scheduledAt: number;  sent: boolean;};
type ReminderAgentState = {  subscriptions: Subscription[];  reminders: Reminder[];};
export class ReminderAgent extends Agent<Env, ReminderAgentState> {  initialState: ReminderAgentState = {    subscriptions: [],    reminders: [],  };
  @callable()  getVapidPublicKey(): string {    return this.env.VAPID_PUBLIC_KEY;  }
  @callable()  async subscribe(subscription: Subscription): Promise<{ ok: boolean }> {    const exists = this.state.subscriptions.some(      (s) => s.endpoint === subscription.endpoint,    );    if (!exists) {      this.setState({        ...this.state,        subscriptions: [...this.state.subscriptions, subscription],      });    }    return { ok: true };  }
  @callable()  async unsubscribe(endpoint: string): Promise<{ ok: boolean }> {    this.setState({      ...this.state,      subscriptions: this.state.subscriptions.filter(        (s) => s.endpoint !== endpoint,      ),    });    return { ok: true };  }
  @callable()  async createReminder(    message: string,    delaySeconds: number,  ): Promise<Reminder> {    const id = crypto.randomUUID();    const scheduledAt = Date.now() + delaySeconds * 1000;    const reminder: Reminder = { id, message, scheduledAt, sent: false };
    this.setState({      ...this.state,      reminders: [...this.state.reminders, reminder],    });
    await this.schedule(delaySeconds, "sendReminder", { id, message });    return reminder;  }
  async sendReminder(payload: { id: string; message: string }) {    webpush.setVapidDetails(      this.env.VAPID_SUBJECT,      this.env.VAPID_PUBLIC_KEY,      this.env.VAPID_PRIVATE_KEY,    );
    const deadEndpoints: string[] = [];
    await Promise.all(      this.state.subscriptions.map(async (sub) => {        try {          await webpush.sendNotification(            sub,            JSON.stringify({              title: "Reminder",              body: payload.message,              tag: `reminder-${payload.id}`,            }),          );        } catch (err: unknown) {          const statusCode =            err instanceof webpush.WebPushError ? err.statusCode : 0;          if (statusCode === 404 || statusCode === 410) {            deadEndpoints.push(sub.endpoint);          }        }      }),    );
    if (deadEndpoints.length > 0) {      this.setState({        ...this.state,        subscriptions: this.state.subscriptions.filter(          (s) => !deadEndpoints.includes(s.endpoint),        ),      });    }
    this.setState({      ...this.state,      reminders: this.state.reminders.map((r) =>        r.id === payload.id ? { ...r, sent: true } : r,      ),    });
    this.broadcast(      JSON.stringify({        type: "reminder_sent",        id: payload.id,        timestamp: Date.now(),      }),    );  }}
export default {  async fetch(request: Request, env: Env) {    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

The `sendReminder` callback handles three things: delivering the push notification via the `web-push` library, cleaning up dead subscriptions (the push service returns 404 or 410 when a subscription is no longer valid), and broadcasting to any connected clients so the UI updates in real time.

## Set up the service worker

The service worker runs in the browser and receives push events even when no tabs are open. Place this file at `public/sw.js` so it is served from the root of your domain:

JavaScript

```
self.addEventListener("push", (event) => {  if (!event.data) return;
  const data = event.data.json();
  event.waitUntil(    self.registration.showNotification(data.title || "Notification", {      body: data.body || "",      icon: data.icon || "/favicon.ico",      tag: data.tag,      data: data.data,    }),  );});
self.addEventListener("notificationclick", (event) => {  event.notification.close();
  event.waitUntil(    self.clients.matchAll({ type: "window" }).then((windowClients) => {      for (const client of windowClients) {        if (          client.url.includes(self.location.origin) &&          "focus" in client        ) {          return client.focus();        }      }      return self.clients.openWindow("/");    }),  );});
```

The `push` event handler parses the JSON payload and displays a native notification. The `notificationclick` handler focuses an existing tab or opens a new one when the user taps the notification.

## Build the client

The client needs to: register the service worker, request notification permission, subscribe to push using the VAPID public key, and send the subscription to the agent.

### Register the service worker

* [  JavaScript ](#tab-panel-5557)
* [  TypeScript ](#tab-panel-5558)

JavaScript

```
useEffect(() => {  if (!("serviceWorker" in navigator) || !("PushManager" in window)) {    return;  }  navigator.serviceWorker.register("/sw.js");}, []);
```

TypeScript

```
useEffect(() => {  if (!("serviceWorker" in navigator) || !("PushManager" in window)) {    return;  }  navigator.serviceWorker.register("/sw.js");}, []);
```

### Subscribe to push

Fetch the VAPID public key from the agent, then subscribe through the Push API:

* [  JavaScript ](#tab-panel-5561)
* [  TypeScript ](#tab-panel-5562)

JavaScript

```
function base64urlToUint8Array(base64url) {  const padded = base64url + "=".repeat((4 - (base64url.length % 4)) % 4);  const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));  const bytes = new Uint8Array(binary.length);  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);  return bytes;}
async function subscribeToPush(agent) {  const permission = await Notification.requestPermission();  if (permission !== "granted") return;
  const vapidPublicKey = await agent.call("getVapidPublicKey");  const reg = await navigator.serviceWorker.ready;  const subscription = await reg.pushManager.subscribe({    userVisibleOnly: true,    applicationServerKey: base64urlToUint8Array(vapidPublicKey).buffer,  });
  const subJson = subscription.toJSON();  await agent.call("subscribe", [    {      endpoint: subJson.endpoint,      expirationTime: subJson.expirationTime ?? null,      keys: subJson.keys,    },  ]);}
```

TypeScript

```
function base64urlToUint8Array(base64url: string): Uint8Array {  const padded = base64url + "=".repeat((4 - (base64url.length % 4)) % 4);  const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));  const bytes = new Uint8Array(binary.length);  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);  return bytes;}
async function subscribeToPush(  agent: ReturnType<typeof useAgent>,) {  const permission = await Notification.requestPermission();  if (permission !== "granted") return;
  const vapidPublicKey = await agent.call("getVapidPublicKey");  const reg = await navigator.serviceWorker.ready;  const subscription = await reg.pushManager.subscribe({    userVisibleOnly: true,    applicationServerKey: base64urlToUint8Array(vapidPublicKey).buffer,  });
  const subJson = subscription.toJSON();  await agent.call("subscribe", [    {      endpoint: subJson.endpoint,      expirationTime: subJson.expirationTime ?? null,      keys: subJson.keys,    },  ]);}
```

### Create reminders

With the subscription stored, creating a reminder is a single RPC call. The agent handles scheduling and delivery:

* [  JavaScript ](#tab-panel-5555)
* [  TypeScript ](#tab-panel-5556)

JavaScript

```
await agent.call("createReminder", ["Check the oven", 300]);
```

TypeScript

```
await agent.call("createReminder", ["Check the oven", 300]);
```

The agent schedules an alarm for 300 seconds (5 minutes). When it fires, the push notification arrives — even if the user closed the tab minutes ago.

## Configuration

### wrangler.jsonc

JSONC

```
{  "name": "push-notifications",  "compatibility_date": "2026-01-28",  "compatibility_flags": ["nodejs_compat"],  "main": "src/server.ts",  "durable_objects": {    "bindings": [      { "name": "ReminderAgent", "class_name": "ReminderAgent" },    ],  },  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ReminderAgent"] }],  "assets": {    "not_found_handling": "single-page-application",  },}
```

The `nodejs_compat` compatibility flag is required for the `web-push` library.

### Dependencies

Terminal window

```
npm install agents web-push
```

## Production considerations

### Subscription expiry

Push subscriptions can expire or be revoked by the user. Always handle 404 and 410 responses from the push service by removing the dead subscription from state, as shown in the `sendReminder` example above.

### Per-user vs shared agents

For most applications, use one agent per user (using the user ID as the agent name). This isolates each user's subscriptions and reminders. For broadcast-style notifications (same message to many users), a shared agent can store all subscriptions, but be aware of the state size as the subscription list grows.

### Combining push with WebSocket broadcast

Use `this.broadcast()` for clients that are currently connected (instant, no push service roundtrip) and Web Push for clients that are offline. The `sendReminder` example above does both — connected clients get a real-time WebSocket message, and offline clients get a push notification.

### Multiple devices

A single user may subscribe from multiple browsers or devices. The agent stores each subscription separately, and `sendReminder` iterates over all of them. Each device receives its own push notification.

### Retry on failure

If the push service returns a 5xx error (temporary failure), you can retry using `this.schedule()` with a short delay:

* [  JavaScript ](#tab-panel-5559)
* [  TypeScript ](#tab-panel-5560)

JavaScript

```
try {  await webpush.sendNotification(sub, payload);} catch (err) {  const statusCode = err instanceof webpush.WebPushError ? err.statusCode : 0;  if (statusCode >= 500) {    await this.schedule(60, "retrySendNotification", {      endpoint: sub.endpoint,      payload,    });  }}
```

TypeScript

```
try {  await webpush.sendNotification(sub, payload);} catch (err: unknown) {  const statusCode =    err instanceof webpush.WebPushError ? err.statusCode : 0;  if (statusCode >= 500) {    await this.schedule(60, "retrySendNotification", {      endpoint: sub.endpoint,      payload,    });  }}
```

## Next steps

[ Schedule tasks ](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) Learn about scheduling and keepAlive for long-running operations. 

[ Store and sync state ](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) Manage agent state for storing subscriptions. 

[ Callable methods ](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/) Expose agent methods as RPC endpoints.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/communication-channels/webhooks/push-notifications/#page","headline":"Push notifications · Cloudflare Agents docs","description":"Send browser push notifications from a Cloudflare Agent, even when the user has closed the tab.","url":"https://developers.cloudflare.com/agents/communication-channels/webhooks/push-notifications/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/communication-channels/","name":"Communication channels"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/communication-channels/webhooks/","name":"Webhooks"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/communication-channels/webhooks/push-notifications/","name":"Push notifications"}}]}
```

---

---
title: Agentic patterns
description: Implement common AI agent patterns like prompt chaining, routing, parallelization, and orchestrator-workers on Cloudflare.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Agentic patterns

This page lists and defines common patterns for implementing AI agents, based on [Anthropic's patterns for building effective agents ↗](https://www.anthropic.com/research/building-effective-agents).

Code samples use the [AI SDK ↗](https://ai-sdk.dev/docs/foundations/agents), running in [Durable Objects](https://developers.cloudflare.com/durable-objects).

## Prompt Chaining

Decomposes tasks into a sequence of steps, where each LLM call processes the output of the previous one.

![Figure 1: Prompt Chaining](https://developers.cloudflare.com/_astro/01-prompt-chaining.BLijYLLo_Z4mjQb.webp) 

TypeScript

```
import { openai } from "@ai-sdk/openai";import { generateText, generateObject } from "ai";import { z } from "zod";
export default async function generateMarketingCopy(input: string) {  const model = openai("gpt-4o");
  // First step: Generate marketing copy  const { text: copy } = await generateText({    model,    prompt: `Write persuasive marketing copy for: ${input}. Focus on benefits and emotional appeal.`,  });
  // Perform quality check on copy  const { object: qualityMetrics } = await generateObject({    model,    schema: z.object({      hasCallToAction: z.boolean(),      emotionalAppeal: z.number().min(1).max(10),      clarity: z.number().min(1).max(10),    }),    prompt: `Evaluate this marketing copy for:    1. Presence of call to action (true/false)    2. Emotional appeal (1-10)    3. Clarity (1-10)
    Copy to evaluate: ${copy}`,  });
  // If quality check fails, regenerate with more specific instructions  if (    !qualityMetrics.hasCallToAction ||    qualityMetrics.emotionalAppeal < 7 ||    qualityMetrics.clarity < 7  ) {    const { text: improvedCopy } = await generateText({      model,      prompt: `Rewrite this marketing copy with:      ${!qualityMetrics.hasCallToAction ? "- A clear call to action" : ""}      ${qualityMetrics.emotionalAppeal < 7 ? "- Stronger emotional appeal" : ""}      ${qualityMetrics.clarity < 7 ? "- Improved clarity and directness" : ""}
      Original copy: ${copy}`,    });    return { copy: improvedCopy, qualityMetrics };  }
  return { copy, qualityMetrics };}
```

## Routing

Classifies input and directs it to specialized followup tasks, allowing for separation of concerns.

![Figure 2: Routing](https://developers.cloudflare.com/_astro/2_Routing.CT-Tgwab_1YYXmR.webp) 

TypeScript

```
import { openai } from '@ai-sdk/openai';import { generateObject, generateText } from 'ai';import { z } from 'zod';
async function handleCustomerQuery(query: string) {  const model = openai('gpt-4o');
  // First step: Classify the query type  const { object: classification } = await generateObject({    model,    schema: z.object({      reasoning: z.string(),      type: z.enum(['general', 'refund', 'technical']),      complexity: z.enum(['simple', 'complex']),    }),    prompt: `Classify this customer query:    ${query}
    Determine:    1. Query type (general, refund, or technical)    2. Complexity (simple or complex)    3. Brief reasoning for classification`,  });
  // Route based on classification  // Set model and system prompt based on query type and complexity  const { text: response } = await generateText({    model:      classification.complexity === 'simple'        ? openai('gpt-4o-mini')        : openai('o1-mini'),    system: {      general:        'You are an expert customer service agent handling general inquiries.',      refund:        'You are a customer service agent specializing in refund requests. Follow company policy and collect necessary information.',      technical:        'You are a technical support specialist with deep product knowledge. Focus on clear step-by-step troubleshooting.',    }[classification.type],    prompt: query,  });
  return { response, classification };}
```

## Parallelization

Enables simultaneous task processing through sectioning or voting mechanisms.

![Figure 3: Parallelization](https://developers.cloudflare.com/_astro/3_Parallelization.gkwf-xnL_1psyLV.webp) 

TypeScript

```
import { openai } from '@ai-sdk/openai';import { generateText, generateObject } from 'ai';import { z } from 'zod';
// Example: Parallel code review with multiple specialized reviewersasync function parallelCodeReview(code: string) {  const model = openai('gpt-4o');
  // Run parallel reviews  const [securityReview, performanceReview, maintainabilityReview] =    await Promise.all([      generateObject({        model,        system:          'You are an expert in code security. Focus on identifying security vulnerabilities, injection risks, and authentication issues.',        schema: z.object({          vulnerabilities: z.array(z.string()),          riskLevel: z.enum(['low', 'medium', 'high']),          suggestions: z.array(z.string()),        }),        prompt: `Review this code:      ${code}`,      }),
      generateObject({        model,        system:          'You are an expert in code performance. Focus on identifying performance bottlenecks, memory leaks, and optimization opportunities.',        schema: z.object({          issues: z.array(z.string()),          impact: z.enum(['low', 'medium', 'high']),          optimizations: z.array(z.string()),        }),        prompt: `Review this code:      ${code}`,      }),
      generateObject({        model,        system:          'You are an expert in code quality. Focus on code structure, readability, and adherence to best practices.',        schema: z.object({          concerns: z.array(z.string()),          qualityScore: z.number().min(1).max(10),          recommendations: z.array(z.string()),        }),        prompt: `Review this code:      ${code}`,      }),    ]);
  const reviews = [    { ...securityReview.object, type: 'security' },    { ...performanceReview.object, type: 'performance' },    { ...maintainabilityReview.object, type: 'maintainability' },  ];
  // Aggregate results using another model instance  const { text: summary } = await generateText({    model,    system: 'You are a technical lead summarizing multiple code reviews.',    prompt: `Synthesize these code review results into a concise summary with key actions:    ${JSON.stringify(reviews, null, 2)}`,  });
  return { reviews, summary };}
```

## Orchestrator-Workers

A central LLM dynamically breaks down tasks, delegates to Worker LLMs, and synthesizes results.

![Figure 4: Orchestrator Workers](https://developers.cloudflare.com/_astro/4_Orchestrator-Workers.jVghtZEj_Z6FePI.webp) 

TypeScript

```
import { openai } from '@ai-sdk/openai';import { generateObject } from 'ai';import { z } from 'zod';
async function implementFeature(featureRequest: string) {  // Orchestrator: Plan the implementation  const { object: implementationPlan } = await generateObject({    model: openai('o1'),    schema: z.object({      files: z.array(        z.object({          purpose: z.string(),          filePath: z.string(),          changeType: z.enum(['create', 'modify', 'delete']),        }),      ),      estimatedComplexity: z.enum(['low', 'medium', 'high']),    }),    system:      'You are a senior software architect planning feature implementations.',    prompt: `Analyze this feature request and create an implementation plan:    ${featureRequest}`,  });
  // Workers: Execute the planned changes  const fileChanges = await Promise.all(    implementationPlan.files.map(async file => {      // Each worker is specialized for the type of change      const workerSystemPrompt = {        create:          'You are an expert at implementing new files following best practices and project patterns.',        modify:          'You are an expert at modifying existing code while maintaining consistency and avoiding regressions.',        delete:          'You are an expert at safely removing code while ensuring no breaking changes.',      }[file.changeType];
      const { object: change } = await generateObject({        model: openai('gpt-4o'),        schema: z.object({          explanation: z.string(),          code: z.string(),        }),        system: workerSystemPrompt,        prompt: `Implement the changes for ${file.filePath} to support:        ${file.purpose}
        Consider the overall feature context:        ${featureRequest}`,      });
      return {        file,        implementation: change,      };    }),  );
  return {    plan: implementationPlan,    changes: fileChanges,  };}
```

## Evaluator-Optimizer

One LLM generates responses while another provides evaluation and feedback in a loop.

![Figure 5: Evaluator-Optimizer](https://developers.cloudflare.com/_astro/5_Evaluator-Optimizer.uXTWfJxj_Z8n6xm.webp) 

TypeScript

```
import { openai } from '@ai-sdk/openai';import { generateText, generateObject } from 'ai';import { z } from 'zod';
async function translateWithFeedback(text: string, targetLanguage: string) {  let currentTranslation = '';  let iterations = 0;  const MAX_ITERATIONS = 3;
  // Initial translation  const { text: translation } = await generateText({    model: openai('gpt-4o-mini'), // use small model for first attempt    system: 'You are an expert literary translator.',    prompt: `Translate this text to ${targetLanguage}, preserving tone and cultural nuances:    ${text}`,  });
  currentTranslation = translation;
  // Evaluation-optimization loop  while (iterations < MAX_ITERATIONS) {    // Evaluate current translation    const { object: evaluation } = await generateObject({      model: openai('gpt-4o'), // use a larger model to evaluate      schema: z.object({        qualityScore: z.number().min(1).max(10),        preservesTone: z.boolean(),        preservesNuance: z.boolean(),        culturallyAccurate: z.boolean(),        specificIssues: z.array(z.string()),        improvementSuggestions: z.array(z.string()),      }),      system: 'You are an expert in evaluating literary translations.',      prompt: `Evaluate this translation:
      Original: ${text}      Translation: ${currentTranslation}
      Consider:      1. Overall quality      2. Preservation of tone      3. Preservation of nuance      4. Cultural accuracy`,    });
    // Check if quality meets threshold    if (      evaluation.qualityScore >= 8 &&      evaluation.preservesTone &&      evaluation.preservesNuance &&      evaluation.culturallyAccurate    ) {      break;    }
    // Generate improved translation based on feedback    const { text: improvedTranslation } = await generateText({      model: openai('gpt-4o'), // use a larger model      system: 'You are an expert literary translator.',      prompt: `Improve this translation based on the following feedback:      ${evaluation.specificIssues.join('\n')}      ${evaluation.improvementSuggestions.join('\n')}
      Original: ${text}      Current Translation: ${currentTranslation}`,    });
    currentTranslation = improvedTranslation;    iterations++;  }
  return {    finalTranslation: currentTranslation,    iterationsRequired: iterations,  };}
```

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/concepts/agentic-patterns/#page","headline":"Agentic patterns · Cloudflare Agents docs","description":"Implement common AI agent patterns like prompt chaining, routing, parallelization, and orchestrator-workers on Cloudflare.","url":"https://developers.cloudflare.com/agents/concepts/agentic-patterns/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/concepts/","name":"Concepts"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/concepts/agentic-patterns/","name":"Agentic patterns"}}]}
```

---

---
title: Human-in-the-loop patterns
description: Implement human-in-the-loop functionality using Cloudflare Agents for workflow approvals and MCP elicitation
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Human-in-the-loop patterns

Human-in-the-loop (HITL) patterns allow agents to pause execution and wait for human approval, confirmation, or input before proceeding. This is essential for compliance, safety, and oversight in agentic systems.

## Why human-in-the-loop?

* **Compliance**: Regulatory requirements may mandate human approval for certain actions
* **Safety**: High-stakes operations (payments, deletions, external communications) need oversight
* **Quality**: Human review catches errors AI might miss
* **Trust**: Users feel more confident when they can approve critical actions

### Common use cases

| Use Case            | Example                              |
| ------------------- | ------------------------------------ |
| Financial approvals | Expense reports, payment processing  |
| Content moderation  | Publishing, email sending            |
| Data operations     | Bulk deletions, exports              |
| AI tool execution   | Confirming tool calls before running |
| Access control      | Granting permissions, role changes   |

## Choosing a pattern

Cloudflare provides two main patterns for human-in-the-loop:

| Pattern               | Best for                                     | Key API           |
| --------------------- | -------------------------------------------- | ----------------- |
| **Workflow approval** | Multi-step processes, durable approval gates | waitForApproval() |
| **MCP elicitation**   | MCP servers requesting structured user input | elicitInput()     |

Decision guide:

* Use **Workflow approval** when you need durable, multi-step processes with approval gates that can wait hours, days, or weeks
* Use **MCP elicitation** when building MCP servers that need to request additional structured input from users during tool execution

## Workflow-based approval

For durable, multi-step processes, use [Cloudflare Workflows](https://developers.cloudflare.com/workflows/) with the `waitForApproval()` method. The workflow pauses until a human approves or rejects.

### Basic pattern

* [  JavaScript ](#tab-panel-5573)
* [  TypeScript ](#tab-panel-5574)

JavaScript

```
import { Agent } from "agents";import { AgentWorkflow } from "agents/workflows";
export class ExpenseWorkflow extends AgentWorkflow {  async run(event, step) {    const expense = event.payload;
    // Step 1: Validate the expense    const validated = await step.do("validate", async () => {      if (expense.amount <= 0) {        throw new Error("Invalid expense amount");      }      return { ...expense, validatedAt: Date.now() };    });
    // Step 2: Report that we are waiting for approval    await this.reportProgress({      step: "approval",      status: "pending",      message: `Awaiting approval for $${expense.amount}`,    });
    // Step 3: Wait for human approval (pauses the workflow)    const approval = await this.waitForApproval(step, {      timeout: "7 days",    });
    console.log(`Approved by: ${approval?.approvedBy}`);
    // Step 4: Process the approved expense    const result = await step.do("process", async () => {      return { expenseId: crypto.randomUUID(), ...validated };    });
    await step.reportComplete(result);    return result;  }}
```

TypeScript

```
import { Agent } from "agents";import { AgentWorkflow } from "agents/workflows";import type { AgentWorkflowEvent, AgentWorkflowStep } from "agents/workflows";
type ExpenseParams = {  amount: number;  description: string;  requestedBy: string;};
export class ExpenseWorkflow extends AgentWorkflow<  ExpenseAgent,  ExpenseParams> {  async run(event: AgentWorkflowEvent<ExpenseParams>, step: AgentWorkflowStep) {    const expense = event.payload;
    // Step 1: Validate the expense    const validated = await step.do("validate", async () => {      if (expense.amount <= 0) {        throw new Error("Invalid expense amount");      }      return { ...expense, validatedAt: Date.now() };    });
    // Step 2: Report that we are waiting for approval    await this.reportProgress({      step: "approval",      status: "pending",      message: `Awaiting approval for $${expense.amount}`,    });
    // Step 3: Wait for human approval (pauses the workflow)    const approval = await this.waitForApproval<{ approvedBy: string }>(step, {      timeout: "7 days",    });
    console.log(`Approved by: ${approval?.approvedBy}`);
    // Step 4: Process the approved expense    const result = await step.do("process", async () => {      return { expenseId: crypto.randomUUID(), ...validated };    });
    await step.reportComplete(result);    return result;  }}
```

### Agent methods for approval

The agent provides methods to approve or reject waiting workflows:

* [  JavaScript ](#tab-panel-5577)
* [  TypeScript ](#tab-panel-5578)

JavaScript

```
import { Agent, callable } from "agents";
export class ExpenseAgent extends Agent {  initialState = {    pendingApprovals: [],  };
  // Approve a waiting workflow  @callable()  async approve(workflowId, approvedBy) {    await this.approveWorkflow(workflowId, {      reason: "Expense approved",      metadata: { approvedBy, approvedAt: Date.now() },    });
    // Update state to reflect approval    this.setState({      ...this.state,      pendingApprovals: this.state.pendingApprovals.filter(        (p) => p.workflowId !== workflowId,      ),    });  }
  // Reject a waiting workflow  @callable()  async reject(workflowId, reason) {    await this.rejectWorkflow(workflowId, { reason });
    this.setState({      ...this.state,      pendingApprovals: this.state.pendingApprovals.filter(        (p) => p.workflowId !== workflowId,      ),    });  }
  // Track workflow progress to update pending approvals  async onWorkflowProgress(workflowName, workflowId, progress) {    const p = progress;
    if (p.step === "approval" && p.status === "pending") {      // Add to pending approvals list for UI display      this.setState({        ...this.state,        pendingApprovals: [          ...this.state.pendingApprovals,          {            workflowId,            amount: 0, // Would come from workflow params            description: p.message || "",            requestedBy: "user",            requestedAt: Date.now(),          },        ],      });    }  }}
```

TypeScript

```
import { Agent, callable } from "agents";
type PendingApproval = {  workflowId: string;  amount: number;  description: string;  requestedBy: string;  requestedAt: number;};
type ExpenseState = {  pendingApprovals: PendingApproval[];};
export class ExpenseAgent extends Agent<Env, ExpenseState> {  initialState: ExpenseState = {    pendingApprovals: [],  };
  // Approve a waiting workflow  @callable()  async approve(workflowId: string, approvedBy: string): Promise<void> {    await this.approveWorkflow(workflowId, {      reason: "Expense approved",      metadata: { approvedBy, approvedAt: Date.now() },    });
    // Update state to reflect approval    this.setState({      ...this.state,      pendingApprovals: this.state.pendingApprovals.filter(        (p) => p.workflowId !== workflowId,      ),    });  }
  // Reject a waiting workflow  @callable()  async reject(workflowId: string, reason: string): Promise<void> {    await this.rejectWorkflow(workflowId, { reason });
    this.setState({      ...this.state,      pendingApprovals: this.state.pendingApprovals.filter(        (p) => p.workflowId !== workflowId,      ),    });  }
  // Track workflow progress to update pending approvals  async onWorkflowProgress(    workflowName: string,    workflowId: string,    progress: unknown,  ): Promise<void> {    const p = progress as { step: string; status: string; message?: string };
    if (p.step === "approval" && p.status === "pending") {      // Add to pending approvals list for UI display      this.setState({        ...this.state,        pendingApprovals: [          ...this.state.pendingApprovals,          {            workflowId,            amount: 0, // Would come from workflow params            description: p.message || "",            requestedBy: "user",            requestedAt: Date.now(),          },        ],      });    }  }}
```

### Timeout handling

Set timeouts to prevent workflows from waiting indefinitely:

* [  JavaScript ](#tab-panel-5567)
* [  TypeScript ](#tab-panel-5568)

JavaScript

```
const approval = await this.waitForApproval(step, {  timeout: "7 days", // Also supports: "1 hour", "30 minutes", etc.});
if (!approval) {  // Timeout expired - escalate or auto-reject  await step.reportError("Approval timeout - escalating to manager");  throw new Error("Approval timeout");}
```

TypeScript

```
const approval = await this.waitForApproval<{ approvedBy: string }>(step, {  timeout: "7 days", // Also supports: "1 hour", "30 minutes", etc.});
if (!approval) {  // Timeout expired - escalate or auto-reject  await step.reportError("Approval timeout - escalating to manager");  throw new Error("Approval timeout");}
```

### Escalation with scheduling

Use `schedule()` to set up escalation reminders:

* [  JavaScript ](#tab-panel-5569)
* [  TypeScript ](#tab-panel-5570)

JavaScript

```
import { Agent, callable } from "agents";
class ExpenseAgent extends Agent {  @callable()  async submitForApproval(expense) {    // Start the approval workflow    const workflowId = await this.runWorkflow("EXPENSE_WORKFLOW", expense);
    // Schedule reminder after 4 hours    await this.schedule(Date.now() + 4 * 60 * 60 * 1000, "sendReminder", {      workflowId,    });
    // Schedule escalation after 24 hours    await this.schedule(Date.now() + 24 * 60 * 60 * 1000, "escalateApproval", {      workflowId,    });
    return workflowId;  }
  async sendReminder(payload) {    const workflow = this.getWorkflow(payload.workflowId);    if (workflow?.status === "waiting") {      // Send reminder notification      console.log("Reminder: approval still pending");    }  }
  async escalateApproval(payload) {    const workflow = this.getWorkflow(payload.workflowId);    if (workflow?.status === "waiting") {      // Escalate to manager      console.log("Escalating to manager");    }  }}
```

TypeScript

```
import { Agent, callable } from "agents";
class ExpenseAgent extends Agent<Env, ExpenseState> {  @callable()  async submitForApproval(expense: ExpenseParams): Promise<string> {    // Start the approval workflow    const workflowId = await this.runWorkflow("EXPENSE_WORKFLOW", expense);
    // Schedule reminder after 4 hours    await this.schedule(Date.now() + 4 * 60 * 60 * 1000, "sendReminder", {      workflowId,    });
    // Schedule escalation after 24 hours    await this.schedule(Date.now() + 24 * 60 * 60 * 1000, "escalateApproval", {      workflowId,    });
    return workflowId;  }
  async sendReminder(payload: { workflowId: string }) {    const workflow = this.getWorkflow(payload.workflowId);    if (workflow?.status === "waiting") {      // Send reminder notification      console.log("Reminder: approval still pending");    }  }
  async escalateApproval(payload: { workflowId: string }) {    const workflow = this.getWorkflow(payload.workflowId);    if (workflow?.status === "waiting") {      // Escalate to manager      console.log("Escalating to manager");    }  }}
```

### Audit trail with SQL

Use `this.sql` to maintain an immutable audit trail:

* [  JavaScript ](#tab-panel-5571)
* [  TypeScript ](#tab-panel-5572)

JavaScript

```
import { Agent, callable } from "agents";
class ExpenseAgent extends Agent {  async onStart() {    // Create audit table    this.sql`      CREATE TABLE IF NOT EXISTS approval_audit (        id INTEGER PRIMARY KEY AUTOINCREMENT,        workflow_id TEXT NOT NULL,        decision TEXT NOT NULL CHECK(decision IN ('approved', 'rejected')),        decided_by TEXT NOT NULL,        decided_at INTEGER NOT NULL,        reason TEXT      )    `;  }
  @callable()  async approve(workflowId, userId, reason) {    // Record the decision in SQL (immutable audit log)    this.sql`      INSERT INTO approval_audit (workflow_id, decision, decided_by, decided_at, reason)      VALUES (${workflowId}, 'approved', ${userId}, ${Date.now()}, ${reason || null})    `;
    // Process the approval    await this.approveWorkflow(workflowId, {      reason: reason || "Approved",      metadata: { approvedBy: userId },    });  }}
```

TypeScript

```
import { Agent, callable } from "agents";
class ExpenseAgent extends Agent<Env, ExpenseState> {  async onStart() {    // Create audit table    this.sql`      CREATE TABLE IF NOT EXISTS approval_audit (        id INTEGER PRIMARY KEY AUTOINCREMENT,        workflow_id TEXT NOT NULL,        decision TEXT NOT NULL CHECK(decision IN ('approved', 'rejected')),        decided_by TEXT NOT NULL,        decided_at INTEGER NOT NULL,        reason TEXT      )    `;  }
  @callable()  async approve(    workflowId: string,    userId: string,    reason?: string,  ): Promise<void> {    // Record the decision in SQL (immutable audit log)    this.sql`      INSERT INTO approval_audit (workflow_id, decision, decided_by, decided_at, reason)      VALUES (${workflowId}, 'approved', ${userId}, ${Date.now()}, ${reason || null})    `;
    // Process the approval    await this.approveWorkflow(workflowId, {      reason: reason || "Approved",      metadata: { approvedBy: userId },    });  }}
```

### Configuration

* [  wrangler.jsonc ](#tab-panel-5565)
* [  wrangler.toml ](#tab-panel-5566)

JSONC

```
{  "name": "expense-approval",  "main": "src/index.ts",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": ["nodejs_compat"],  "durable_objects": {    "bindings": [{ "name": "EXPENSE_AGENT", "class_name": "ExpenseAgent" }],  },  "workflows": [    {      "name": "expense-workflow",      "binding": "EXPENSE_WORKFLOW",      "class_name": "ExpenseWorkflow",    },  ],  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ExpenseAgent"] }],}
```

TOML

```
name = "expense-approval"main = "src/index.ts"# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = [ "nodejs_compat" ]
[[durable_objects.bindings]]name = "EXPENSE_AGENT"class_name = "ExpenseAgent"
[[workflows]]name = "expense-workflow"binding = "EXPENSE_WORKFLOW"class_name = "ExpenseWorkflow"
[[migrations]]tag = "v1"new_sqlite_classes = [ "ExpenseAgent" ]
```

## MCP elicitation

When building MCP servers with `McpAgent`, you can request additional user input during tool execution using **elicitation**. The MCP client renders a form based on your JSON Schema and returns the user's response.

### Basic pattern

* [  JavaScript ](#tab-panel-5579)
* [  TypeScript ](#tab-panel-5580)

JavaScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
export class CounterMCP extends McpAgent {  server = new McpServer({    name: "counter-server",    version: "1.0.0",  });
  initialState = { counter: 0 };
  async init() {    this.server.tool(      "increase-counter",      "Increase the counter by a user-specified amount",      { confirm: z.boolean().describe("Do you want to increase the counter?") },      async ({ confirm }, extra) => {        if (!confirm) {          return { content: [{ type: "text", text: "Cancelled." }] };        }
        // Request additional input from the user        const userInput = await this.server.server.elicitInput(          {            message: "By how much do you want to increase the counter?",            requestedSchema: {              type: "object",              properties: {                amount: {                  type: "number",                  title: "Amount",                  description: "The amount to increase the counter by",                },              },              required: ["amount"],            },          },          { relatedRequestId: extra.requestId },        );
        // Check if user accepted or cancelled        if (userInput.action !== "accept" || !userInput.content) {          return { content: [{ type: "text", text: "Cancelled." }] };        }
        // Use the input        const amount = Number(userInput.content.amount);        this.setState({          ...this.state,          counter: this.state.counter + amount,        });
        return {          content: [            {              type: "text",              text: `Counter increased by ${amount}, now at ${this.state.counter}`,            },          ],        };      },    );  }}
```

TypeScript

```
import { McpAgent } from "agents/mcp";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";
type State = { counter: number };
export class CounterMCP extends McpAgent<Env, State, {}> {  server = new McpServer({    name: "counter-server",    version: "1.0.0",  });
  initialState: State = { counter: 0 };
  async init() {    this.server.tool(      "increase-counter",      "Increase the counter by a user-specified amount",      { confirm: z.boolean().describe("Do you want to increase the counter?") },      async ({ confirm }, extra) => {        if (!confirm) {          return { content: [{ type: "text", text: "Cancelled." }] };        }
        // Request additional input from the user        const userInput = await this.server.server.elicitInput(          {            message: "By how much do you want to increase the counter?",            requestedSchema: {              type: "object",              properties: {                amount: {                  type: "number",                  title: "Amount",                  description: "The amount to increase the counter by",                },              },              required: ["amount"],            },          },          { relatedRequestId: extra.requestId },        );
        // Check if user accepted or cancelled        if (userInput.action !== "accept" || !userInput.content) {          return { content: [{ type: "text", text: "Cancelled." }] };        }
        // Use the input        const amount = Number(userInput.content.amount);        this.setState({          ...this.state,          counter: this.state.counter + amount,        });
        return {          content: [            {              type: "text",              text: `Counter increased by ${amount}, now at ${this.state.counter}`,            },          ],        };      },    );  }}
```

## Elicitation vs workflow approval

| Aspect       | MCP Elicitation               | Workflow Approval             |
| ------------ | ----------------------------- | ----------------------------- |
| **Context**  | MCP server tool execution     | Multi-step workflow processes |
| **Duration** | Immediate (within tool call)  | Can wait hours/days/weeks     |
| **UI**       | JSON Schema-based form        | Custom UI via agent state     |
| **State**    | MCP session state             | Durable workflow state        |
| **Use case** | Interactive input during tool | Approval gates in pipelines   |

## Building approval UIs

### Pending approvals list

Use the agent's state to display pending approvals in your UI:

```
import { useAgent } from "agents/react";
function PendingApprovals() {  const { state, agent } = useAgent({    agent: "expense-agent",    name: "main",  });
  if (!state?.pendingApprovals?.length) {    return <p>No pending approvals</p>;  }
  return (    <div className="approval-list">      {state.pendingApprovals.map((item) => (        <div key={item.workflowId} className="approval-card">          <h3>${item.amount}</h3>          <p>{item.description}</p>          <p>Requested by {item.requestedBy}</p>
          <div className="actions">            <button              onClick={() => agent.stub.approve(item.workflowId, "admin")}            >              Approve            </button>            <button              onClick={() => agent.stub.reject(item.workflowId, "Declined")}            >              Reject            </button>          </div>        </div>      ))}    </div>  );}
```

## Multi-approver patterns

For sensitive operations requiring multiple approvers:

* [  JavaScript ](#tab-panel-5575)
* [  TypeScript ](#tab-panel-5576)

JavaScript

```
import { Agent, callable } from "agents";
class MultiApprovalAgent extends Agent {  @callable()  async approveMulti(workflowId, userId) {    const approval = this.state.pendingMultiApprovals.find(      (p) => p.workflowId === workflowId,    );    if (!approval) throw new Error("Approval not found");
    // Check if user already approved    if (approval.currentApprovals.some((a) => a.userId === userId)) {      throw new Error("Already approved by this user");    }
    // Add this user's approval    approval.currentApprovals.push({ userId, approvedAt: Date.now() });
    // Check if we have enough approvals    if (approval.currentApprovals.length >= approval.requiredApprovals) {      // Execute the approved action      await this.approveWorkflow(workflowId, {        metadata: { approvers: approval.currentApprovals },      });      return true;    }
    this.setState({ ...this.state });    return false; // Still waiting for more approvals  }}
```

TypeScript

```
import { Agent, callable } from "agents";
type MultiApproval = {  workflowId: string;  requiredApprovals: number;  currentApprovals: Array<{ userId: string; approvedAt: number }>;  rejections: Array<{ userId: string; rejectedAt: number; reason: string }>;};
type State = {  pendingMultiApprovals: MultiApproval[];};
class MultiApprovalAgent extends Agent<Env, State> {  @callable()  async approveMulti(workflowId: string, userId: string): Promise<boolean> {    const approval = this.state.pendingMultiApprovals.find(      (p) => p.workflowId === workflowId,    );    if (!approval) throw new Error("Approval not found");
    // Check if user already approved    if (approval.currentApprovals.some((a) => a.userId === userId)) {      throw new Error("Already approved by this user");    }
    // Add this user's approval    approval.currentApprovals.push({ userId, approvedAt: Date.now() });
    // Check if we have enough approvals    if (approval.currentApprovals.length >= approval.requiredApprovals) {      // Execute the approved action      await this.approveWorkflow(workflowId, {        metadata: { approvers: approval.currentApprovals },      });      return true;    }
    this.setState({ ...this.state });    return false; // Still waiting for more approvals  }}
```

## Best practices

1. **Define clear approval criteria** — Only require confirmation for actions with meaningful consequences (payments, emails, data changes)
2. **Provide detailed context** — Show users exactly what the action will do, including all arguments
3. **Implement timeouts** — Use `schedule()` to escalate or auto-reject after reasonable periods
4. **Maintain audit trails** — Use `this.sql` to record all approval decisions for compliance
5. **Handle connection drops** — Store pending approvals in agent state so they survive disconnections
6. **Graceful degradation** — Provide fallback behavior if approvals are rejected

## Next steps

[ Run Workflows ](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/) Complete waitForApproval() API reference. 

[ MCP servers ](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/) Build MCP agents with elicitation. 

[ Email notifications ](https://developers.cloudflare.com/email-service/api/send-emails/workers-api/) Send notifications for pending approvals. 

[ Schedule tasks ](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) Implement approval timeouts with schedules.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/concepts/agentic-patterns/human-in-the-loop/#page","headline":"Human-in-the-loop patterns · Cloudflare Agents docs","description":"Implement human-in-the-loop functionality using Cloudflare Agents for workflow approvals and MCP elicitation","url":"https://developers.cloudflare.com/agents/concepts/agentic-patterns/human-in-the-loop/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-09","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/concepts/","name":"Concepts"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/concepts/agentic-patterns/","name":"Agentic patterns"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/concepts/agentic-patterns/human-in-the-loop/","name":"Human-in-the-loop patterns"}}]}
```

---

---
title: Long-running agents
description: Build agents that persist for days, weeks, or months — surviving restarts, waking on demand, and managing work that spans far longer than any single request.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Long-running agents

Build agents that persist for days, weeks, or months — surviving restarts, waking on demand, and managing work that spans far longer than any single request.

The short version:

* Agents are durable identities, not always-on processes.
* State, SQL data, schedules, and fiber checkpoints survive hibernation and restarts.
* In-memory variables, timers, open fetches, and local closures do not survive eviction.
* Use `keepAlive()` for active work measured in minutes, `runFiber()` when work needs recovery, `startFiber()` when callers need durable acceptance and status, and Workflows for heavyweight multi-step jobs.
* Use sub-agents when one parent coordinates many long-lived child contexts.

## Why Cloudflare for long-running agents

Agents spend most of their time waiting. Waiting for user input (seconds to days), LLM responses (seconds to minutes), tool results (seconds to hours), human approvals (hours to days), or scheduled wake-ups (minutes to months). On a traditional VM or container, you pay for all that idle time. An agent that is 99% dormant and 1% active still costs you 100% of a server.

Durable Objects invert this model. An agent exists as an addressable entity with persistent state, but consumes zero compute when hibernated. When something happens — an HTTP request, a WebSocket message, a scheduled alarm, an inbound email — the platform wakes the agent, loads its state from SQLite, and hands it the event. The agent does its work, then goes back to sleep.

This is the [actor model ↗](https://en.wikipedia.org/wiki/Actor%5Fmodel): each agent has an identity, durable state, and wakes on message. You do not manage servers, routing, health checks, or restart logic. The platform handles placement, scaling, and recovery.

The economics follow directly:

|                                               | VMs / Containers                               | Durable Objects                   |
| --------------------------------------------- | ---------------------------------------------- | --------------------------------- |
| **Idle cost**                                 | Full compute cost, always                      | Zero (hibernated)                 |
| **Scaling**                                   | Provision and manage capacity                  | Automatic, per-agent              |
| **State**                                     | External database required                     | Built-in SQLite                   |
| **Recovery**                                  | You build it (process managers, health checks) | Platform restarts, state survives |
| **Identity / routing**                        | You build it (load balancers, sticky sessions) | Built-in (name to agent)          |
| **10,000 agents, each active 1% of the time** | 10,000 always-on instances                     | \~100 active at any moment        |

For agents — which are inherently bursty, stateful, and long-lived — this is a natural fit.

## The lifecycle of a long-running agent

A long-running agent is not a process that runs continuously. It is an entity that **exists** continuously but **runs** intermittently. Understanding the lifecycle is key to building agents that work reliably over long timelines.

```
Wake → onStart() → handle events → idle (~2 min) → hibernation  ▲                                                      │  └──────────────── alarm or request wakes agent ────────┘
Eviction (crash / redeploy) can happen at any point.State persists in SQLite. Agent restarts on next event.
```

### What survives

* **`this.state`** — persisted to SQLite on every `setState()` call
* **`this.sql` data** — all SQLite tables you create
* **Scheduled tasks** — stored in SQLite, trigger alarms to wake the agent
* **Connection state** — `connection.setState()` data for each WebSocket client
* **Fiber checkpoints and ledgers** — `stash()` data from `runFiber()` and retained `startFiber()` status rows

Any higher-level abstractions built on SQLite also survive, since they share the same durable storage.

### What does not survive

* **In-memory variables** — class fields not stored via `setState()` or `this.sql`
* **Running timers** — `setTimeout`, `setInterval` are lost on hibernation/eviction
* **Open fetch requests** — in-flight HTTP calls are abandoned
* **Local closures** — callbacks and promise chains are lost

The implication: any work that matters must be persisted or recoverable. The SDK provides primitives for this — schedules, fibers, queues — but understanding the boundary between "in-memory" and "durable" is essential.

## Running example: a project manager agent

Throughout this doc, we build up a project manager agent that:

* Lives for the duration of a project (weeks or months)
* Tracks tasks, assigns work to sub-agents, and reports progress
* Wakes up on schedule to check deadlines and send reminders
* Reacts to external events (webhooks from GitHub, emails from team members)
* Handles long-running operations (CI pipelines, code reviews, deployments)
* Survives any number of restarts and evictions along the way

TypeScript

```
import { Agent } from "agents";
type ProjectState = {  name: string;  status: "planning" | "active" | "review" | "complete";  tasks: Task[];  plan: Plan | null;};
type Task = {  id: string;  title: string;  status: "pending" | "in_progress" | "blocked" | "complete";  assignee?: string;  dueDate?: string;  completedAt?: number;  externalJobId?: string;};
export class ProjectManager extends Agent<ProjectState> {  initialState: ProjectState = {    name: "",    status: "planning",    tasks: [],    plan: null,  };}
```

The `Plan` type is introduced in [Planning as a durability strategy](#planning-as-a-durability-strategy). We add capabilities to this agent section by section.

## Waking up: how agents get activated

A hibernated agent can be woken by any of these sources:

| Wake source              | How it works                                                                                                                                                                                                                                 | Example                                 |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| **HTTP request**         | Any request to the agent's URL triggers onRequest()                                                                                                                                                                                          | A webhook from GitHub                   |
| **WebSocket connection** | A client connects, triggering onConnect()                                                                                                                                                                                                    | A team member opens the dashboard       |
| **RPC call**             | Another Worker or agent calls a method via [service binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) or [@callable](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/) | A coordinator agent delegates a task    |
| **Scheduled alarm**      | A stored schedule fires, triggering your callback                                                                                                                                                                                            | Daily standup reminder at 9am           |
| **Email**                | An inbound email triggers onEmail()                                                                                                                                                                                                          | A team member replies to a status email |

The pattern extends naturally to any event source that can reach a Worker — anything from telephony webhooks to chat platform bots. An external signal arrives, the platform wakes the agent, and the agent handles it.

The agent does not need to be "started" or "deployed" separately for each wake source — they all route to the same Durable Object instance. The agent's identity (its name) is the routing key.

TypeScript

```
export class ProjectManager extends Agent<ProjectState> {  async onStart() {    // Daily deadline check at 9am UTC — idempotent, safe across restarts    await this.schedule(      "0 9 * * *",      "checkDeadlines",      {},      {        idempotent: true,      },    );
    // Progress sync every 30 minutes    await this.scheduleEvery(1800, "syncProgress");  }
  async onRequest(request: Request): Promise<Response> {    const url = new URL(request.url);
    if (url.pathname.endsWith("/github-webhook")) {      const event = await request.json();      await this.handleGitHubEvent(event);      return new Response("OK");    }
    return Response.json({      project: this.state.name,      status: this.state.status,    });  }
  async checkDeadlines() {    /* ... find overdue tasks, broadcast alerts ... */  }  async syncProgress() {    /* ... check on sub-agents, update task statuses ... */  }}
```

## Staying alive during long work

Sometimes an agent needs to do work that takes longer than the idle eviction window (\~70–140 seconds). Streaming an LLM response, orchestrating a multi-step tool chain, or waiting on a slow API all risk the agent being evicted mid-flight.

`keepAlive()` prevents this by creating a heartbeat that resets the inactivity timer:

TypeScript

```
export class ProjectManager extends Agent<ProjectState> {  async generateProjectPlan(goal: string) {    const result = await this.keepAliveWhile(async () => {      const plan = await this.callLLM(`Create a project plan for: ${goal}`);      const tasks = await this.callLLM(        `Break this into tasks: ${JSON.stringify(plan)}`,      );      return { plan, tasks };    });
    this.setState({      ...this.state,      status: "active",      plan: result.plan,      tasks: result.tasks,    });  }}
```

`keepAliveWhile()` is the recommended approach — it guarantees the heartbeat is cleaned up when the work finishes (or throws). For manual control, `keepAlive()` returns a disposer:

TypeScript

```
const dispose = await this.keepAlive();try {  await longWork();} finally {  dispose();}
```

### When keepAlive is not enough

`keepAlive` is for work measured in minutes, not hours. For truly long-running operations, use a different strategy:

| Duration         | Strategy                                                                               |
| ---------------- | -------------------------------------------------------------------------------------- |
| Seconds          | Normal request handling                                                                |
| Minutes          | keepAlive() / keepAliveWhile()                                                         |
| Minutes          | startFiber() when retryable acceptance matters                                         |
| Minutes to hours | [Workflows](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/) |
| Hours to days    | Async pattern: start job, hibernate, wake on completion                                |

## Surviving crashes: fibers and recovery

An agent can be evicted at any time — a deploy, a platform restart, or hitting resource limits. If the agent was mid-task, that work is lost unless it was checkpointed.

[runFiber()](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/) provides crash-recoverable execution. It persists a row in SQLite for the duration of the work, and lets you `stash()` intermediate state. If the agent is evicted, the fiber row survives, and `onFiberRecovered()` is called on the next activation.

Use [startFiber()](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/#startfiber) when the important boundary is durable acceptance. It adds an idempotency key, retained status records, inspection, cancellation, and cleanup on top of the same fiber machinery. By default it returns after acceptance; pass `waitForCompletion: true` when the request should stay open until the accepted job reaches a terminal status. This is a good fit for webhooks where the provider may retry delivery and the agent must avoid starting duplicate visible side effects.

TypeScript

```
export class ProjectManager extends Agent<ProjectState> {  async executeTask(task: Task) {    await this.runFiber(`task:${task.id}`, async (ctx) => {      const resources = await this.gatherResources(task);      ctx.stash({ phase: "prepared", resources, task });
      const result = await this.runSubAgent(task, resources);      ctx.stash({ phase: "executed", result, task });
      await this.updateTaskStatus(task.id, "complete", result);    });  }
  async onFiberRecovered(ctx: FiberRecoveryContext) {    if (!ctx.name.startsWith("task:")) return;    const { phase, task } = ctx.snapshot as { phase: string; task: Task };
    if (phase === "prepared") {      await this.executeTask(task);    } else if (phase === "executed") {      await this.updateTaskStatus(        task.id,        "complete",        (ctx.snapshot as { result: unknown }).result,      );    }  }}
```

The pattern is: **checkpoint before expensive work, recover from the last checkpoint.** This is not automatic replay — you decide what recovery means for your domain.

Testing recovery locally

In `wrangler dev`, fiber recovery works identically to production. Kill the wrangler process (Ctrl-C or SIGKILL), restart it, and recovery fires automatically. SQLite and alarm state persist to disk between restarts.

For the full API reference — `FiberContext`, `FiberRecoveryContext`, concurrent fibers, inline vs fire-and-forget patterns — refer to [Durable Execution](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/).

## Handling long async operations

The project manager frequently kicks off work that takes far longer than any single activation — a CI pipeline runs for 20 minutes, a design review takes a day, a video asset takes hours to generate. The agent should not stay alive for any of this. Instead, it starts the work, persists the job ID in state, and hibernates. When the result arrives — via a callback, a poll, or a workflow completion — the agent wakes, correlates the result, and moves on.

### Pattern: webhook callback

The project manager starts a CI pipeline for a task. The pipeline takes 20 minutes. Rather than holding a connection open, the agent registers its own URL as the callback and goes to sleep:

TypeScript

```
export class ProjectManager extends Agent<ProjectState> {  async startCIPipeline(task: Task) {    const response = await fetch("https://ci.example.com/api/pipelines", {      method: "POST",      body: JSON.stringify({        repo: "org/project",        branch: "main",        callback_url: `${this.url}/ci-callback?taskId=${task.id}`,      }),    });
    const { pipelineId } = await response.json();    this.updateTask(task.id, {      status: "in_progress",      externalJobId: pipelineId,    });  }
  async onRequest(request: Request): Promise<Response> {    const url = new URL(request.url);    if (url.pathname.endsWith("/ci-callback")) {      const taskId = url.searchParams.get("taskId");      const result = await request.json();      this.updateTask(taskId, {        status: result.status === "success" ? "complete" : "blocked",      });      return new Response("OK");    }    // ... other routes  }}
```

### Pattern: polling with schedule

Not every external service supports callbacks. When the project manager submits a video asset for generation, it needs to check back periodically until the job completes:

TypeScript

```
export class ProjectManager extends Agent<ProjectState> {  async startVideoGeneration(task: Task) {    const response = await fetch("https://video-api.example.com/generate", {      method: "POST",      body: JSON.stringify({ prompt: task.title }),    });    const { jobId } = await response.json();    this.updateTask(task.id, { status: "in_progress", externalJobId: jobId });    await this.schedule(60, "pollExternalJob", {      taskId: task.id,      jobId,      attempt: 1,    });  }
  async pollExternalJob(payload: {    taskId: string;    jobId: string;    attempt: number;  }) {    const response = await fetch(      `https://video-api.example.com/status/${payload.jobId}`,    );    const status = await response.json();
    if (status.state === "complete" || status.state === "failed") {      this.updateTask(payload.taskId, {        status: status.state === "complete" ? "complete" : "blocked",      });      return;    }
    const nextDelay = Math.min(60 * payload.attempt, 600);    await this.schedule(nextDelay, "pollExternalJob", {      ...payload,      attempt: payload.attempt + 1,    });  }}
```

### Pattern: workflow delegation

A production deployment involves multiple steps that must each retry independently — build, test, stage, promote. The project manager should not manage these steps internally; it delegates to a [Workflow](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/) that handles retries and step sequencing:

TypeScript

```
export class ProjectManager extends Agent<ProjectState> {  async startDeployment(task: Task) {    const instanceId = await this.runWorkflow("DEPLOY_WORKFLOW", {      taskId: task.id,      environment: "production",    });    this.updateTask(task.id, {      status: "in_progress",      externalJobId: instanceId,    });  }
  async onWorkflowComplete(    workflowName: string,    instanceId: string,    result?: unknown,  ) {    const task = this.state.tasks.find((t) => t.externalJobId === instanceId);    if (task) this.updateTask(task.id, { status: "complete" });  }}
```

## Reconstructing context after a long wait

The CI pipeline finishes 20 minutes later. The webhook wakes the project manager. The task status is updated. But now what? If the agent was using an LLM to orchestrate work — deciding which task to run next, drafting a status report, reasoning about blockers — it needs to pick up that reasoning thread. The original prompt, the in-flight tool call, the chain of thought — all gone from memory.

This is the fundamental challenge of long-running AI agents. Most frameworks assume tool calls complete within the LLM's timeout and do not address this directly.

Three approaches work today:

**Replay the full conversation history.** `AIChatAgent` persists all messages in SQLite. When the result arrives, append it to the history and re-invoke the LLM. This is the simplest approach but re-processes the entire context window.

**Stash a continuation summary.** Before hibernating, persist a compact description of what the agent was doing and what to do with the result:

TypeScript

```
ctx.stash({  task: "Waiting for CI results",  onSuccess: "Mark task complete, move to next step in plan",  onFailure: "Notify team, schedule retry in 1 hour",  relevantContext: { taskId, planStep: 3 },});
```

On recovery, use the stash to construct a focused prompt rather than replaying everything.

**Use the plan as context.** If the agent has a structured plan, the plan itself provides sufficient context: "I am on step 3 of 7, the step was 'run CI pipeline', the result just arrived." This is the most robust approach for long-running agents — the plan is both a recovery mechanism and a context reconstruction strategy. Refer to the next section.

## Planning as a durability strategy

A structured plan is not just useful for showing progress to users — it is a durability mechanism. An agent with a plan can recover from any interruption by looking at where it left off.

TypeScript

```
type Plan = {  goal: string;  steps: PlanStep[];  currentStep: number;  createdAt: string;  updatedAt: string;};
type PlanStep = {  id: string;  description: string;  status: "pending" | "in_progress" | "complete" | "failed" | "skipped";  result?: unknown;};
export class ProjectManager extends Agent<ProjectState> {  async createPlan(goal: string) {    const steps = await this.keepAliveWhile(async () => {      return this.callLLM(`        Break down this project goal into concrete steps.        Return a JSON array of { id, description } objects.        Goal: ${goal}      `);    });
    this.setState({      ...this.state,      plan: {        goal,        steps: steps.map((s: { id: string; description: string }) => ({          ...s,          status: "pending" as const,        })),        currentStep: 0,        createdAt: new Date().toISOString(),        updatedAt: new Date().toISOString(),      },    });
    await this.schedule(0, "executeNextStep");  }
  async executeNextStep() {    const { plan } = this.state;    if (!plan || plan.currentStep >= plan.steps.length) {      this.setState({ ...this.state, status: "complete" });      return;    }
    const step = plan.steps[plan.currentStep];
    try {      const result = await this.keepAliveWhile(() => this.executeStep(step));
      const updatedSteps = plan.steps.map((s) =>        s.id === step.id ? { ...s, status: "complete" as const, result } : s,      );      this.setState({        ...this.state,        plan: {          ...plan,          steps: updatedSteps,          currentStep: plan.currentStep + 1,          updatedAt: new Date().toISOString(),        },      });
      await this.schedule(0, "executeNextStep");    } catch (error) {      const updatedSteps = plan.steps.map((s) =>        s.id === step.id ? { ...s, status: "failed" as const } : s,      );      this.setState({        ...this.state,        plan: {          ...plan,          steps: updatedSteps,          updatedAt: new Date().toISOString(),        },      });    }  }}
```

This pattern has several advantages for long-running agents:

* **Recovery is trivial** — on restart, check `plan.currentStep` and resume
* **Progress is visible** — clients see which steps are done and what is next
* **Re-planning is possible** — if a step fails or requirements change, the agent can revise the remaining steps without losing completed work
* **Human oversight** — the plan is a natural approval checkpoint ("here is what I am going to do — proceed?")
* **Context reconstruction** — the plan tells the LLM where it is, what happened, and what to do next, without replaying the full conversation

## Delegating to sub-agents

A project manager does not do everything itself. It delegates specialized work to sub-agents — each with their own identity, state, and lifecycle.

TypeScript

```
export class ProjectManager extends Agent<ProjectState> {  async delegateTask(task: Task) {    const researcher = await this.subAgent(      ResearchAgent,      `research-${task.id}`,    );
    const findings = await researcher.research(task.title);
    this.updateTask(task.id, { status: "complete" });    return findings;  }}
```

Sub-agents have their own state, schedules, durable fibers, and lifecycle. They are colocated under the parent, but each child stores its own SQLite data and runs callbacks with the child as `this`.

Because facets do not have independent alarm slots, the top-level parent owns the physical Durable Object alarm. The Agents SDK records which sub-agent owns each schedule or fiber recovery lease, wakes the parent, and routes the callback back into the child. The parent does not need to stay active while the sub-agent works — it can start the work, hibernate, and be woken by the child-owned schedule or recovery check.

For the full `subAgent()` API — typed RPC stubs, client routing, access control, storage isolation, and alarm-backed APIs — refer to [Sub-agents](https://developers.cloudflare.com/agents/runtime/execution/sub-agents/). For AI-specific sub-agent streaming (running full LLM turns through a child agent), refer to [Think: Sub-agent RPC](https://developers.cloudflare.com/agents/harnesses/think/sub-agents/).

## Recovering interrupted LLM streams

The patterns above handle the project manager's coordination work — scheduling, delegating, polling. But the project manager also uses an LLM directly: generating plans, summarizing progress, drafting status emails. Those LLM calls stream tokens over a connection that cannot be resumed if the agent is evicted mid-response.

For chat-oriented agents built on `AIChatAgent`, this is an even sharper problem — the user is watching the response stream in real time and sees it stop mid-sentence. `chatRecovery` wraps each chat turn in a `runFiber`, providing automatic `keepAlive` during streaming and a recovery hook when the agent restarts:

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import type {  ChatRecoveryContext,  ChatRecoveryOptions,} from "@cloudflare/ai-chat";
class ProjectChat extends AIChatAgent<Env> {  override chatRecovery = true;
  override async onChatRecovery(    ctx: ChatRecoveryContext,  ): Promise<ChatRecoveryOptions> {    // ctx.partialText    — text generated before eviction    // ctx.recoveryData   — whatever you stashed via this.stash()    // ctx.messages        — full conversation history    // ctx.createdAt       — when the interrupted turn started    return {};  }}
```

The right recovery strategy depends on the LLM provider:

| Provider               | Strategy                            | How it works                                                                | Token cost |
| ---------------------- | ----------------------------------- | --------------------------------------------------------------------------- | ---------- |
| Workers AI             | Continue from partial               | continueLastTurn() — model continues via assistant prefill                  | Low        |
| OpenAI (Responses API) | Retrieve completed response         | Stash responseId during streaming, retrieve on recovery                     | Zero       |
| Anthropic              | Synthetic continuation              | Persist partial, send a synthetic user message asking the model to continue | Medium     |
| Other                  | Try prefill, fall back to synthetic | continueLastTurn() if the provider supports it, synthetic message otherwise | Varies     |

Use `ctx.createdAt` to suppress stale recoveries. For example, if a recovered chat turn is older than a few minutes, you may persist the partial answer but skip automatic continuation to avoid surprising the user with an old response.

[Think](https://developers.cloudflare.com/agents/harnesses/think/) enables `chatRecovery` by default. The default path persists partial output and auto-continues or retries the turn when safe, so many apps do not need a custom hook. Override `onChatRecovery` when a provider has a better recovery strategy, or configure `chatRecovery = { maxAttempts, terminalMessage, onExhausted }` to tune the terminal user experience.

If the agent is interrupted before any assistant stream chunks are written, there is no partial assistant message to continue. When the latest persisted message is still the unanswered user message from that turn, chat recovery retries the turn automatically unless `onChatRecovery` returns `{ continue: false }`.

## Managing state over time

An agent that runs for months accumulates data: conversation history, timeline events, completed tasks, schedule records. Without management, this grows unbounded.

### Housekeeping

Schedule periodic cleanup to prune old data and archive completed work:

TypeScript

```
export class ProjectManager extends Agent<ProjectState> {  async onStart() {    await this.schedule("0 0 * * *", "housekeeping", {}, { idempotent: true });  }
  async housekeeping() {    const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;    const toArchive = this.state.tasks.filter(      (t) => t.status === "complete" && (t.completedAt ?? 0) < cutoff,    );    for (const task of toArchive) {      this        .sql`INSERT INTO archived_tasks (id, data) VALUES (${task.id}, ${JSON.stringify(task)})`;    }    this.setState({      ...this.state,      tasks: this.state.tasks.filter(        (t) => !toArchive.some((a) => a.id === t.id),      ),    });
    this.deleteWorkflows({      status: ["complete", "errored"],      createdBefore: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),    });  }}
```

### Conversation history management

For agents that use `AIChatAgent`, conversation history can grow large over extended lifespans. Without management, a 3-month conversation will exhaust the LLM's context window long before the project ends.

Strategies for managing conversation size:

* **Sliding window** — keep only the last N messages in the active context. Simple and predictable.
* **Summarization** — periodically summarize older messages and replace them with a compact summary. Original messages can remain in SQLite for audit.
* **Selective retention** — retain messages that contain decisions, approvals, and key context while pruning routine exchanges.

## End of life

A long-running agent eventually completes its purpose. The project ships, the investigation concludes, the monitoring window closes. Clean up explicitly:

TypeScript

```
export class ProjectManager extends Agent<ProjectState> {  async completeProject() {    const schedules = await this.listSchedules();    for (const schedule of schedules) {      await this.cancelSchedule(schedule.id);    }
    this.setState({ ...this.state, status: "complete" });
    // All SQLite data, schedules, and state are permanently deleted    await this.destroy();  }}
```

`this.destroy()` is permanent. If you may need the agent's data later, archive it to an external store (R2, D1, or an API call) before destroying. For agents that might be reactivated, simply mark them as complete and let them hibernate — they cost nothing when idle.

## When to use Workflows vs agent-internal patterns

Both Workflows and agent-internal primitives (schedules, fibers, queues) support long-running work. The right choice depends on the nature of the work:

|                    | Agent-internal                                         | Workflows                                |
| ------------------ | ------------------------------------------------------ | ---------------------------------------- |
| **Best for**       | Agent-centric work: scheduling, polling, state updates | Independent multi-step pipelines         |
| **Durability**     | SQLite (survives eviction)                             | Workflow engine (survives everything)    |
| **Retries**        | this.retry(), schedule-level retries                   | Per-step retries with backoff            |
| **Max duration**   | Minutes per activation (with keepAlive)                | 30 minutes per step, unlimited steps     |
| **Human approval** | Build it yourself (state + WebSocket)                  | Built-in waitForApproval()               |
| **Complexity**     | Lower — everything is in the agent                     | Higher — separate class, wrangler config |

A pragmatic rule: if the work is about the agent managing its own lifecycle (checking deadlines, syncing state, sending reminders), use schedules and fibers. If the work is a discrete pipeline that could fail and retry independently (deploy, data processing, report generation), use a Workflow.

The project manager agent uses both: schedules for its own rhythms (daily standups, progress syncs), and Workflows for heavyweight operations (deployments, CI pipelines).

## Summary

Long-running agents on Cloudflare are not long-running processes. They are durable entities that wake, work, and sleep — potentially over weeks or months. The key primitives:

| Primitive                          | Purpose                                      |
| ---------------------------------- | -------------------------------------------- |
| **setState() / this.sql**          | Persist state across activations             |
| **schedule() / scheduleEvery()**   | Wake the agent at future times               |
| **keepAlive() / keepAliveWhile()** | Prevent eviction during active work          |
| **runFiber() / stash()**           | Checkpoint and recover long tasks            |
| **startFiber()**                   | Durably accept, inspect, and cancel jobs     |
| **chatRecovery**                   | Recover interrupted LLM streams              |
| **onRequest() / onEmail() / RPC**  | Wake on external events                      |
| **runWorkflow()**                  | Delegate heavyweight multi-step work         |
| **subAgent()**                     | Delegate specialized work to child agents    |
| **Structured plans in state**      | Enable recovery, visibility, and re-planning |

For the project manager agent, these compose into an agent that:

1. **Plans** — breaks goals into steps, persists the plan in state
2. **Executes** — runs steps one at a time, hibernating between them
3. **Reacts** — wakes on webhooks, emails, and schedules
4. **Recovers** — resumes from the last checkpoint after any interruption
5. **Delegates** — hands off work to sub-agents and Workflows
6. **Maintains** — prunes old data, archives completed work, manages its own lifecycle
7. **Ends** — cleans up and destroys itself when the project is done

The agent does not need to run continuously to do any of this. It just needs to exist.

## Related

* [Durable Execution](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/) — `runFiber()`, `startFiber()`, `stash()`, and crash recovery
* [Schedule tasks](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) — delayed, cron, and interval tasks
* [Retries](https://developers.cloudflare.com/agents/runtime/execution/retries/) — retry options and patterns
* [Workflows](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/) — durable multi-step processing
* [Store and sync state](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) — `setState()` and persistence
* [WebSockets](https://developers.cloudflare.com/agents/runtime/communication/websockets/) — lifecycle hooks and hibernation
* [Callable methods](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/) — RPC via `@callable` and service bindings
* [Email routing](https://developers.cloudflare.com/agents/communication-channels/email/) — receiving inbound email
* [Webhooks](https://developers.cloudflare.com/agents/communication-channels/webhooks/) — receiving external events
* [Human in the loop](https://developers.cloudflare.com/agents/concepts/agentic-patterns/human-in-the-loop/) — approval flows

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/concepts/agentic-patterns/long-running-agents/#page","headline":"Long-running agents · Cloudflare Agents docs","description":"Build agents that persist for days, weeks, or months — surviving restarts, waking on demand, and managing work that spans far longer than any single request.","url":"https://developers.cloudflare.com/agents/concepts/agentic-patterns/long-running-agents/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/concepts/","name":"Concepts"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/concepts/agentic-patterns/","name":"Agentic patterns"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/concepts/agentic-patterns/long-running-agents/","name":"Long-running agents"}}]}
```

---

---
title: Calling LLMs
description: Call large language models from within a stateful Cloudflare Agent with persistent context and autonomous scheduling.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Calling LLMs

Agents change how you work with LLMs. In a stateless Worker, every request starts from scratch — you reconstruct context, call a model, return the response, and forget everything. An Agent keeps state between calls, stays connected to clients over WebSocket, and can call models on its own schedule without a user present.

This page covers the patterns that become possible when your LLM calls happen inside a stateful Agent. For provider setup and code examples, refer to [Using AI Models](https://developers.cloudflare.com/agents/runtime/operations/using-ai-models/).

## State as context

Every Agent has a built-in [SQL database](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) and key-value state. Instead of passing an entire conversation history from the client on every request, the Agent stores it and builds prompts from its own storage.

* [  JavaScript ](#tab-panel-5581)
* [  TypeScript ](#tab-panel-5582)

JavaScript

```
import { Agent } from "agents";
export class ResearchAgent extends Agent {  async buildPrompt(userMessage) {    const history = this.sql`      SELECT role, content FROM messages      ORDER BY timestamp DESC LIMIT 50`;
    const preferences = this.sql`      SELECT key, value FROM user_preferences`;
    return [      { role: "system", content: this.systemPrompt(preferences) },      ...history.reverse(),      { role: "user", content: userMessage },    ];  }}
```

TypeScript

```
import { Agent } from "agents";
export class ResearchAgent extends Agent<Env> {  async buildPrompt(userMessage: string) {    const history = this.sql<{ role: string; content: string }>`      SELECT role, content FROM messages      ORDER BY timestamp DESC LIMIT 50`;
    const preferences = this.sql<{ key: string; value: string }>`      SELECT key, value FROM user_preferences`;
    return [      { role: "system", content: this.systemPrompt(preferences) },      ...history.reverse(),      { role: "user", content: userMessage },    ];  }}
```

This means the client does not need to send the full conversation on every message. The Agent owns the history, can prune it, enrich it with retrieved documents, or summarize older turns before sending to the model.

## Surviving disconnections

Reasoning models like DeepSeek R1 or GLM-4 can take 30 seconds to several minutes to respond. In a stateless request-response architecture, the client must stay connected the entire time. If the connection drops, the response is lost.

An Agent keeps running after the client disconnects. When the response arrives, the Agent can persist it to state and deliver it when the client reconnects — even hours or days later.

* [  JavaScript ](#tab-panel-5583)
* [  TypeScript ](#tab-panel-5584)

JavaScript

```
import { Agent } from "agents";import { streamText } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class MyAgent extends Agent {  async onMessage(connection, message) {    const { prompt } = JSON.parse(message);    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt,    });
    for await (const chunk of result.textStream) {      connection.send(JSON.stringify({ type: "chunk", content: chunk }));    }
    this.sql`INSERT INTO responses (prompt, response, timestamp)      VALUES (${prompt}, ${await result.text}, ${Date.now()})`;  }}
```

TypeScript

```
import { Agent } from "agents";import { streamText } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class MyAgent extends Agent<Env> {  async onMessage(connection: Connection, message: WSMessage) {    const { prompt } = JSON.parse(message as string);    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt,    });
    for await (const chunk of result.textStream) {      connection.send(JSON.stringify({ type: "chunk", content: chunk }));    }
    this.sql`INSERT INTO responses (prompt, response, timestamp)      VALUES (${prompt}, ${await result.text}, ${Date.now()})`;  }}
```

With [AIChatAgent](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/), this is handled automatically — messages are persisted to SQLite and streams resume on reconnect.

## Autonomous model calls

Agents do not need a user request to call a model. You can schedule model calls to run in the background — for nightly summarization, periodic classification, monitoring, or any task that should happen without human interaction.

* [  JavaScript ](#tab-panel-5585)
* [  TypeScript ](#tab-panel-5586)

JavaScript

```
import { Agent } from "agents";
export class DigestAgent extends Agent {  async onStart() {    this.schedule("0 8 * * *", "generateDailyDigest", {});  }
  async generateDailyDigest() {    const articles = this.sql`      SELECT title, body FROM articles      WHERE created_at > datetime('now', '-1 day')`;
    const workersai = createWorkersAI({ binding: this.env.AI });    const { text } = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: `Summarize these articles:\n${articles.map((a) => a.title + ": " + a.body).join("\n\n")}`,    });
    this.sql`INSERT INTO digests (summary, created_at)      VALUES (${text}, ${Date.now()})`;
    this.broadcast(JSON.stringify({ type: "digest", summary: text }));  }}
```

TypeScript

```
import { Agent } from "agents";
export class DigestAgent extends Agent<Env> {  async onStart() {    this.schedule("0 8 * * *", "generateDailyDigest", {});  }
  async generateDailyDigest() {    const articles = this.sql<{ title: string; body: string }>`      SELECT title, body FROM articles      WHERE created_at > datetime('now', '-1 day')`;
    const workersai = createWorkersAI({ binding: this.env.AI });    const { text } = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: `Summarize these articles:\n${articles.map((a) => a.title + ": " + a.body).join("\n\n")}`,    });
    this.sql`INSERT INTO digests (summary, created_at)      VALUES (${text}, ${Date.now()})`;
    this.broadcast(JSON.stringify({ type: "digest", summary: text }));  }}
```

## Multi-model pipelines

Because an Agent maintains state across calls, you can chain multiple models in a single method — using a fast model for classification, a reasoning model for planning, and an embedding model for retrieval — without losing context between steps.

* [  JavaScript ](#tab-panel-5589)
* [  TypeScript ](#tab-panel-5590)

JavaScript

```
import { Agent } from "agents";import { generateText, embed } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class TriageAgent extends Agent {  async triage(ticket) {    const workersai = createWorkersAI({ binding: this.env.AI });
    const { text: category } = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: `Classify this support ticket into one of: billing, technical, account. Ticket: ${ticket}`,    });
    const { embedding } = await embed({      model: workersai("@cf/baai/bge-base-en-v1.5"),      value: ticket,    });    const similar = await this.env.VECTOR_DB.query(embedding, { topK: 5 });
    const { text: response } = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: `Draft a response for this ${category} ticket. Similar resolved tickets: ${JSON.stringify(similar)}. Ticket: ${ticket}`,    });
    this.sql`INSERT INTO tickets (content, category, response, created_at)      VALUES (${ticket}, ${category}, ${response}, ${Date.now()})`;
    return { category, response };  }}
```

TypeScript

```
import { Agent } from "agents";import { generateText, embed } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class TriageAgent extends Agent<Env> {  async triage(ticket: string) {    const workersai = createWorkersAI({ binding: this.env.AI });
    const { text: category } = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: `Classify this support ticket into one of: billing, technical, account. Ticket: ${ticket}`,    });
    const { embedding } = await embed({      model: workersai("@cf/baai/bge-base-en-v1.5"),      value: ticket,    });    const similar = await this.env.VECTOR_DB.query(embedding, { topK: 5 });
    const { text: response } = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: `Draft a response for this ${category} ticket. Similar resolved tickets: ${JSON.stringify(similar)}. Ticket: ${ticket}`,    });
    this.sql`INSERT INTO tickets (content, category, response, created_at)      VALUES (${ticket}, ${category}, ${response}, ${Date.now()})`;
    return { category, response };  }}
```

Each intermediate result stays in the Agent's memory for the duration of the method, and the final result is persisted to SQL for future reference.

## Caching and cost control

Persistent storage means you can cache model responses and avoid redundant calls. This is especially useful for expensive operations like embeddings or long reasoning chains.

* [  JavaScript ](#tab-panel-5587)
* [  TypeScript ](#tab-panel-5588)

JavaScript

```
import { Agent } from "agents";
export class CachingAgent extends Agent {  async cachedGenerate(prompt) {    const cached = this.sql`      SELECT response FROM llm_cache WHERE prompt = ${prompt}`;
    if (cached.length > 0) {      return cached[0].response;    }
    const workersai = createWorkersAI({ binding: this.env.AI });    const { text } = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt,    });
    this.sql`INSERT INTO llm_cache (prompt, response, created_at)      VALUES (${prompt}, ${text}, ${Date.now()})`;
    return text;  }}
```

TypeScript

```
import { Agent } from "agents";
export class CachingAgent extends Agent<Env> {  async cachedGenerate(prompt: string) {    const cached = this.sql<{ response: string }>`      SELECT response FROM llm_cache WHERE prompt = ${prompt}`;
    if (cached.length > 0) {      return cached[0].response;    }
    const workersai = createWorkersAI({ binding: this.env.AI });    const { text } = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt,    });
    this.sql`INSERT INTO llm_cache (prompt, response, created_at)      VALUES (${prompt}, ${text}, ${Date.now()})`;
    return text;  }}
```

For provider-level caching and rate limit management across multiple agents, use [AI Gateway](https://developers.cloudflare.com/ai-gateway/).

## Next steps

[ Using AI Models ](https://developers.cloudflare.com/agents/runtime/operations/using-ai-models/) Provider setup, streaming, and code examples for Workers AI, OpenAI, Anthropic, and more. 

[ Chat agents ](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/) AIChatAgent handles message persistence, resumable streaming, and tools automatically. 

[ Store and sync state ](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) SQL database and key-value state APIs for building context and caching. 

[ Schedule tasks ](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) Run autonomous model calls on a delay, schedule, or cron.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/concepts/calling-llms/#page","headline":"Calling LLMs · Cloudflare Agents docs","description":"Call large language models from within a stateful Cloudflare Agent with persistent context and autonomous scheduling.","url":"https://developers.cloudflare.com/agents/concepts/calling-llms/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/concepts/","name":"Concepts"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/concepts/calling-llms/","name":"Calling LLMs"}}]}
```

---

---
title: Conversation state and memory
description: How agents store and recall information, including read-only context, writable short-form memory, searchable knowledge, and on-demand skills.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Conversation state and memory

Agents need memory to be useful over time. Without it, every conversation starts from zero. The agent forgets who the user is, what it learned, and what it was doing. Memory is what turns a stateless LLM call into a persistent, context-aware agent.

The [Session API](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/) provides the memory layer for agents built on the Cloudflare Agents SDK. It manages two kinds of memory: **conversation history** (the messages and tool calls that make up a session) and **context memory** (persistent blocks injected into the system prompt that the agent can read, write, search, and load).

Use this page when you need more than simple synced state or flat chat history. For small UI state, use [Store and sync state](https://developers.cloudflare.com/agents/runtime/lifecycle/state/). For basic chat persistence, `AIChatAgent` stores messages for you. For opinionated long-term memory, Think builds on Session and context blocks.

Experimental

The Session memory APIs currently use `agents/experimental/memory/session`. The concepts are stable, but import paths and details may change before graduation.

## Conversation history

The most fundamental type of memory is the conversation itself: the messages between the user and the agent, the tool calls the agent made, and the results it received. The Session stores all of this in a tree-structured message history backed by a Session Provider, defaulting to SQLite.

* [  JavaScript ](#tab-panel-5595)
* [  TypeScript ](#tab-panel-5596)

JavaScript

```
import { Session } from "agents/experimental/memory/session";
// Append messages as the conversation progressesawait session.appendMessage({  id: `user-${crypto.randomUUID()}`,  role: "user",  parts: [{ type: "text", text: "What's the status of the deployment?" }],});
// Read the full conversation historyconst history = await session.getHistory();
```

TypeScript

```
import { Session } from "agents/experimental/memory/session";
// Append messages as the conversation progressesawait session.appendMessage({  id: `user-${crypto.randomUUID()}`,  role: "user",  parts: [{ type: "text", text: "What's the status of the deployment?" }],});
// Read the full conversation historyconst history = await session.getHistory();
```

Conversation history persists across Durable Object hibernation and eviction. When the agent wakes up, the full history is available in SQLite. It does not need to be replayed or reconstructed.

Messages are stored in a tree structure via `parent_id`, which enables branching conversations. When you `appendMessage` with a `parentId` that already has children, you create a branch, useful for features like response regeneration. `getHistory(leafId)` walks any chosen path through the tree.

The Session also provides full-text search across the conversation history:

* [  JavaScript ](#tab-panel-5593)
* [  TypeScript ](#tab-panel-5594)

JavaScript

```
const results = await session.search("deployment Friday", { limit: 10 });
```

TypeScript

```
const results = await session.search("deployment Friday", { limit: 10 });
```

As conversations grow long, [compaction](#compaction) summarizes older messages to keep the context window manageable without losing the underlying data.

## Context memory

Context memory is persistent information injected into the system prompt, separate from the conversation history. It gives the agent access to identity, instructions, learned facts, knowledge bases, and reference material across every turn.

The Session API supports four types of context memory, each suited to different kinds of information. The type is determined by the **provider** backing the context block. The Session detects the provider's capabilities automatically.

### Read-only context

This is your traditional system prompt: the agent's identity, personality, and instructions. You might write it directly in your codebase, load it from a `SOUL.md` file in R2, or fetch it from an API. The content is injected into the system prompt and the agent cannot modify it.

A coding assistant might have a soul that defines its personality and constraints:

* [  JavaScript ](#tab-panel-5597)
* [  TypeScript ](#tab-panel-5598)

JavaScript

```
import { Session } from "agents/experimental/memory/session";
const session = Session.create(this).withContext("soul", {  provider: {    get: async () =>      "You are a senior TypeScript engineer. You write concise, " +      "well-tested code. You prefer composition over inheritance. " +      "When you are unsure, you say so rather than guessing.",  },});
```

TypeScript

```
import { Session } from "agents/experimental/memory/session";
const session = Session.create(this).withContext("soul", {  provider: {    get: async () =>      "You are a senior TypeScript engineer. You write concise, " +      "well-tested code. You prefer composition over inheritance. " +      "When you are unsure, you say so rather than guessing.",  },});
```

Or load it from R2 so you can update the agent's personality without redeploying:

* [  JavaScript ](#tab-panel-5599)
* [  TypeScript ](#tab-panel-5600)

JavaScript

```
const session = Session.create(this).withContext("soul", {  provider: {    get: async () => {      const obj = await env.CONFIG_BUCKET.get("soul.md");      return obj ? obj.text() : "You are a helpful assistant.";    },  },});
```

TypeScript

```
const session = Session.create(this).withContext("soul", {  provider: {    get: async () => {      const obj = await env.CONFIG_BUCKET.get("soul.md");      return obj ? obj.text() : "You are a helpful assistant.";    },  },});
```

Read-only blocks are defined by providing an object with only a `get()` method. No tools are generated. The content appears in the system prompt and the agent has no way to change it.

### Writable short-form context

Think of this as a scratchpad the agent maintains for itself, a place to jot down things it needs to remember. Like how Claude Code keeps a todo list of tasks to work through, or how a customer support agent might track what it has learned about the user during the conversation.

* [  JavaScript ](#tab-panel-5601)
* [  TypeScript ](#tab-panel-5602)

JavaScript

```
const session = Session.create(this)  .withContext("memory", {    description: "Important facts learned during conversation",    maxTokens: 1100,  })  .withContext("todos", {    description: "Task list, track what needs to be done and what is complete",    maxTokens: 2000,  });
```

TypeScript

```
const session = Session.create(this)  .withContext("memory", {    description: "Important facts learned during conversation",    maxTokens: 1100,  })  .withContext("todos", {    description: "Task list, track what needs to be done and what is complete",    maxTokens: 2000,  });
```

When you omit the `provider` option in the builder, the Session auto-wires to a SQLite-backed writable provider. The agent gets a `set_context` tool that lets it replace or append content to these blocks. Token limits are enforced, so the agent cannot write more than the `maxTokens` budget allows.

The system prompt renders writable blocks with a token usage indicator so the agent knows how much space it has left:

```
══════════════════════════════════════════════MEMORY (Important facts learned during conversation) [45% — 495/1100 tokens] [writable]══════════════════════════════════════════════User prefers dark mode.User's project uses React and TypeScript.Deployment target is Cloudflare Workers.
══════════════════════════════════════════════TODOS (Task list) [12% — 240/2000 tokens] [writable]══════════════════════════════════════════════- [x] Set up project scaffolding- [ ] Add authentication middleware- [ ] Write integration tests
```

The content persists across messages and survives hibernation. It is always visible in the system prompt, so the agent sees it on every turn without needing to fetch anything.

### Searchable context

When you have a large body of information (a knowledge base, documentation, logs, accumulated notes) you do not want to stuff it all into the system prompt. Searchable context keeps a summary in the system prompt (for example, "42 entries indexed") and lets the agent retrieve specific entries when it needs them.

You provide a provider with a `search()` method. How that search works is entirely up to you: full-text search, vector search via [Vectorize](https://developers.cloudflare.com/vectorize/), a call to an external API, or anything else. The Session does not care about the implementation, only that the provider has a `search()` method.

The built-in `AgentSearchProvider` uses Durable Object SQLite with FTS5 as default:

* [  JavaScript ](#tab-panel-5603)
* [  TypeScript ](#tab-panel-5604)

JavaScript

```
import { AgentSearchProvider } from "agents/experimental/memory/session";
const session = Session.create(this).withContext("knowledge", {  description:    "Searchable knowledge base, search for relevant information before answering",  provider: new AgentSearchProvider(this),});
```

TypeScript

```
import { AgentSearchProvider } from "agents/experimental/memory/session";
const session = Session.create(this).withContext("knowledge", {  description:    "Searchable knowledge base, search for relevant information before answering",  provider: new AgentSearchProvider(this),});
```

But you can implement your own provider backed by any search mechanism:

* [  JavaScript ](#tab-panel-5609)
* [  TypeScript ](#tab-panel-5610)

JavaScript

```
const session = Session.create(this).withContext("knowledge", {  description: "Searchable knowledge base",  provider: {    get: async () => "Product documentation and FAQs",    search: async (query) => {      // Use Vectorize, an external API, whatever you need      const results = await env.VECTORIZE_INDEX.query(        await generateEmbedding(query),        { topK: 5 },      );      return results.matches.map((m) => m.metadata.text).join("\n\n");    },    set: async (key, content) => {      // Index new content    },  },});
```

TypeScript

```
const session = Session.create(this).withContext("knowledge", {  description: "Searchable knowledge base",  provider: {    get: async () => "Product documentation and FAQs",    search: async (query) => {      // Use Vectorize, an external API, whatever you need      const results = await env.VECTORIZE_INDEX.query(        await generateEmbedding(query),        { topK: 5 },      );      return results.matches.map((m) => m.metadata.text).join("\n\n");    },    set: async (key, content) => {      // Index new content    },  },});
```

The agent gets a `search_context` tool for querying and a `set_context` tool for indexing new entries. It decides what to search for, and you decide how the search works.

This is the right choice when the agent needs to find specific pieces of information from a large collection, rather than loading entire documents.

### Loadable context (Skills)

Skills are large pieces of context (complete documents, reference guides, runbooks, templates) that the agent can discover and load on demand. Think of them as reference material on a shelf: the agent sees a list of titles and descriptions, picks what is relevant to the current task, loads it, uses it, and unloads it when done.

Unlike searchable context where the agent retrieves small chunks from a larger collection, skills are designed to be loaded whole. When an agent loads a skill, it gets the entire document in its context window.

Skills are backed by the `SkillProvider` interface. A skill provider has three methods:

* **`get()`** returns a metadata listing (titles and descriptions) that appears in the system prompt
* **`load(key)`** fetches the full content of a specific skill
* **`set(key, content, description?)`** writes or updates a skill entry (optional)

The system prompt shows available skills as a listing. The `[loadable]` tag tells the LLM that these entries are not inline. It needs to use a tool to access the full content:

```
══════════════════════════════════════════════SKILLS [loadable]══════════════════════════════════════════════- api-ref: API Reference documentation- style-guide: Company style guide- deploy-checklist: Production deployment checklist
```

The agent sees the titles, decides which skill is relevant to the current task, and uses `load_context` to pull the full content into its working context. When it is done, it uses `unload_context` to free the space. When the skill provider implements `set()`, the agent can also write back, updating existing skills or creating new ones.

```
Agent sees: "- deploy-checklist: Production deployment checklist"User asks: "Walk me through a production deployment"Agent calls: load_context({ block: "skills", key: "deploy-checklist" })→ Full checklist content is loaded into the agent's working context
```

#### R2-backed skills

The built-in `R2SkillProvider` stores skills in a Cloudflare R2 bucket. Each skill is an R2 object with optional custom metadata for descriptions.

* [  JavaScript ](#tab-panel-5615)
* [  TypeScript ](#tab-panel-5616)

JavaScript

```
import { Session, R2SkillProvider } from "agents/experimental/memory/session";
const session = Session.create(this)  .withContext("soul", {    provider: {      get: async () =>        [          "You are a helpful assistant with access to skills.",          "When a user asks you to do something, check the SKILLS section",          "for a relevant skill and use load_context to load it.",        ].join("\n"),    },  })  .withContext("memory", {    description: "Learned facts",    maxTokens: 1100,  })  .withContext("skills", {    provider: new R2SkillProvider(env.SKILLS_BUCKET, { prefix: "skills/" }),  })  .withCachedPrompt();
```

TypeScript

```
import { Session, R2SkillProvider } from "agents/experimental/memory/session";
const session = Session.create(this)  .withContext("soul", {    provider: {      get: async () =>        [          "You are a helpful assistant with access to skills.",          "When a user asks you to do something, check the SKILLS section",          "for a relevant skill and use load_context to load it.",        ].join("\n"),    },  })  .withContext("memory", {    description: "Learned facts",    maxTokens: 1100,  })  .withContext("skills", {    provider: new R2SkillProvider(env.SKILLS_BUCKET, { prefix: "skills/" }),  })  .withCachedPrompt();
```

The `prefix` option scopes the provider to a subdirectory in the bucket. Skill keys in the metadata listing are shown without the prefix, so `skills/api-ref` becomes `api-ref` in the system prompt.

Use `keys` to allowlist specific prefix-relative skills for `get()` and `load()`:

* [  JavaScript ](#tab-panel-5605)
* [  TypeScript ](#tab-panel-5606)

JavaScript

```
new R2SkillProvider(env.SKILLS_BUCKET, {  prefix: "skills/",  keys: ["deploy-checklist", "api-ref"],});
```

TypeScript

```
new R2SkillProvider(env.SKILLS_BUCKET, {  prefix: "skills/",  keys: ["deploy-checklist", "api-ref"],});
```

Add an R2 bucket binding to your Wrangler configuration:

* [  wrangler.jsonc ](#tab-panel-5591)
* [  wrangler.toml ](#tab-panel-5592)

JSONC

```
{  "r2_buckets": [    {      "binding": "SKILLS_BUCKET",      "bucket_name": "my-agent-skills"    }  ]}
```

TOML

```
[[r2_buckets]]binding = "SKILLS_BUCKET"bucket_name = "my-agent-skills"
```

Skills are regular R2 objects. Upload them through any R2 interface (the Wrangler CLI, the dashboard, or the Workers API):

Terminal window

```
# Upload a skill from a filewrangler r2 object put my-agent-skills/skills/style-guide --file ./docs/style-guide.md --content-type text/markdown
```

To add descriptions (shown in the metadata listing), set custom metadata on the R2 object:

* [  JavaScript ](#tab-panel-5607)
* [  TypeScript ](#tab-panel-5608)

JavaScript

```
await env.SKILLS_BUCKET.put("skills/api-ref", content, {  customMetadata: { description: "API Reference documentation" },});
```

TypeScript

```
await env.SKILLS_BUCKET.put("skills/api-ref", content, {  customMetadata: { description: "API Reference documentation" },});
```

#### Custom skill providers

You can back skills with any storage by implementing the `SkillProvider` interface:

* [  JavaScript ](#tab-panel-5621)
* [  TypeScript ](#tab-panel-5622)

JavaScript

```
class DatabaseSkillProvider {  db;
  constructor(db) {    this.db = db;  }
  async get() {    const rows = await this.db      .prepare("SELECT key, description FROM skills ORDER BY key")      .all();    if (rows.results.length === 0) return null;    return rows.results      .map((r) => `- ${r.key}${r.description ? `: ${r.description}` : ""}`)      .join("\n");  }
  async load(key) {    const row = await this.db      .prepare("SELECT content FROM skills WHERE key = ?")      .bind(key)      .first();    return row ? row.content : null;  }
  async set(key, content, description) {    await this.db      .prepare(        "INSERT INTO skills (key, content, description) VALUES (?, ?, ?) " +          "ON CONFLICT(key) DO UPDATE SET content = ?, description = ?",      )      .bind(key, content, description ?? null, content, description ?? null)      .run();  }}
```

TypeScript

```
import type { SkillProvider } from "agents/experimental/memory/session";
class DatabaseSkillProvider implements SkillProvider {  private db: D1Database;
  constructor(db: D1Database) {    this.db = db;  }
  async get(): Promise<string | null> {    const rows = await this.db      .prepare("SELECT key, description FROM skills ORDER BY key")      .all();    if (rows.results.length === 0) return null;    return rows.results      .map((r) => `- ${r.key}${r.description ? `: ${r.description}` : ""}`)      .join("\n");  }
  async load(key: string): Promise<string | null> {    const row = await this.db      .prepare("SELECT content FROM skills WHERE key = ?")      .bind(key)      .first();    return row ? (row.content as string) : null;  }
  async set(key: string, content: string, description?: string): Promise<void> {    await this.db      .prepare(        "INSERT INTO skills (key, content, description) VALUES (?, ?, ?) " +          "ON CONFLICT(key) DO UPDATE SET content = ?, description = ?",      )      .bind(key, content, description ?? null, content, description ?? null)      .run();  }}
```

The Session detects the `load()` method via duck-typing and generates the appropriate tools automatically.

#### Skills vs other memory types

| Aspect               | Skills                              | Writable context         | Searchable context                 |
| -------------------- | ----------------------------------- | ------------------------ | ---------------------------------- |
| **In system prompt** | Metadata listing only               | Full content             | Summary count                      |
| **Access pattern**   | Load whole document by key          | Always visible           | Search by query                    |
| **Best for**         | Large documents, reference material | Short notes, preferences | Large collections of small entries |
| **Context cost**     | Low (until loaded)                  | Proportional to content  | Low (until searched)               |
| **Agent writes?**    | Optional (if set implemented)       | Yes (via set\_context)   | Yes (via set\_context)             |

The key distinction: skills are **lazy**. They cost nearly nothing in the system prompt until the agent decides it needs one. This makes them ideal for large reference material where only a subset is relevant to any given conversation.

## How agents interact with memory

The Session automatically generates tools based on the provider types of your context blocks. You pass these tools to your LLM alongside your own application-specific tools:

* [  JavaScript ](#tab-panel-5611)
* [  TypeScript ](#tab-panel-5612)

JavaScript

```
const sessionTools = await session.tools();const allTools = { ...sessionTools, ...myApplicationTools };
const result = streamText({  model: myModel,  system: await session.freezeSystemPrompt(),  messages: await convertToModelMessages(await session.getHistory()),  tools: allTools,});
```

TypeScript

```
const sessionTools = await session.tools();const allTools = { ...sessionTools, ...myApplicationTools };
const result = streamText({  model: myModel,  system: await session.freezeSystemPrompt(),  messages: await convertToModelMessages(await session.getHistory()),  tools: allTools,});
```

### Generated tools

The Session generates tools dynamically based on what provider types are present:

| Tool                | Generated when                              | What it does                                                                                                              |
| ------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| **set\_context**    | Any writable, skill, or search block exists | Writes content to a named block. For writable blocks, replaces or appends. For skill/search blocks, writes a keyed entry. |
| **load\_context**   | Any skill block exists                      | Loads full content of a document by key into the agent's context.                                                         |
| **unload\_context** | Any skill block exists                      | Frees context space by removing a previously loaded document. The document remains available for re-loading.              |
| **search\_context** | Any search block exists                     | Full-text search within a searchable block. Returns the top results ranked by relevance.                                  |
| **session\_search** | Using SessionManager                        | Searches across all sessions (cross-conversation search).                                                                 |

The tools include descriptions and parameter schemas that tell the LLM which blocks are available and what they are for. The agent decides when and how to use them based on the conversation.

For the full tool signatures and all Session methods, refer to the [Session API reference](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/).

## The system prompt

Context blocks are assembled into a structured system prompt with clear headers and metadata. Each block gets a labeled section with tags indicating its type and capacity:

```
══════════════════════════════════════════════SOUL (Identity) [readonly]══════════════════════════════════════════════You are a helpful coding assistant who speaks concisely.
══════════════════════════════════════════════MEMORY (Important facts) [45% — 495/1100 tokens] [writable]══════════════════════════════════════════════User prefers dark mode.User's project uses React and TypeScript.
══════════════════════════════════════════════KNOWLEDGE (Searchable knowledge base) [searchable]══════════════════════════════════════════════12 entries indexed.
══════════════════════════════════════════════SKILLS [loadable]══════════════════════════════════════════════- api-ref: API Reference documentation- style-guide: Company style guide
```

The tags (`[readonly]`, `[writable]`, `[searchable]`, `[loadable]`) tell the LLM what kind of interaction is possible with each block. Token budgets show the agent how much space remains in writable blocks, helping it manage its own memory.

## Gotchas

### Prompt caching

LLM providers (Anthropic, OpenAI, and others) cache the system prompt prefix. When consecutive requests share the same system prompt, the provider can skip re-processing that prefix, reducing both latency and cost. Breaking the cache (by changing the system prompt) throws away this benefit.

The Session API is designed to work with prompt caching:

* **`freezeSystemPrompt()`** renders the system prompt from all context blocks on the first call, then returns the cached value on subsequent calls. The prompt does not change between turns, even when the agent writes to memory via `set_context`.
* **`withCachedPrompt()`** persists the frozen prompt to storage, so it survives Durable Object hibernation and eviction. When the agent wakes up, it loads the same prompt without re-fetching from all providers.

When the agent uses `set_context` to update a writable block, the underlying provider is updated immediately (the data is saved), but the frozen system prompt is **not** re-rendered. The LLM sees the update on its next turn only if you explicitly call `refreshSystemPrompt()`, which you typically do between conversation turns, not mid-turn.

This means the system prompt stays stable throughout a multi-step tool-use turn, preserving the provider's prefix cache across every step.

* [  JavaScript ](#tab-panel-5617)
* [  TypeScript ](#tab-panel-5618)

JavaScript

```
const session = Session.create(this)  .withContext("soul", {    provider: { get: async () => "You are a helpful assistant." },  })  .withContext("memory", { description: "Learned facts", maxTokens: 1100 })  .withCachedPrompt(); // Persist the frozen prompt across hibernation
// During a conversation turn:const system = await session.freezeSystemPrompt(); // Same value every callconst tools = await session.tools();
// ... agent calls set_context to update memory ...// The frozen prompt is NOT changed, prefix cache stays warm
// Between turns (optional, if you want the agent to see its own updates):await session.refreshSystemPrompt();
```

TypeScript

```
const session = Session.create(this)  .withContext("soul", {    provider: { get: async () => "You are a helpful assistant." },  })  .withContext("memory", { description: "Learned facts", maxTokens: 1100 })  .withCachedPrompt(); // Persist the frozen prompt across hibernation
// During a conversation turn:const system = await session.freezeSystemPrompt(); // Same value every callconst tools = await session.tools();
// ... agent calls set_context to update memory ...// The frozen prompt is NOT changed, prefix cache stays warm
// Between turns (optional, if you want the agent to see its own updates):await session.refreshSystemPrompt();
```

### Compaction

Long conversations eventually exceed the LLM's context window. Compaction addresses this at two levels: **macro-compaction** summarizes ranges of older messages, and **micro-compaction** truncates individual messages that are too large.

#### Macro-compaction

Macro-compaction summarizes older messages, but it never deletes the originals.

It uses **overlays**: a summary is stored in a separate table, keyed by the message range it covers. When `getHistory()` is called, overlays are applied transparently at read time. The compacted range is replaced by a synthetic summary message. The underlying messages remain in SQLite, preserving the full conversation for audit, search, and branching.

```
Messages:  [1] [2] [3] [4] [5] [6] [7] [8] [9] [10]                    ↓ compaction ↓Overlay:   [1] [2] [SUMMARY of 3-7]           [8] [9] [10]                                                ↑ tail protected
```

The key points:

* **Non-destructive**, original messages are never deleted. The full conversation is always available in the database.
* **Iterative**, when the conversation grows again and triggers another compaction, the existing summary is passed to the LLM to update, not replaced from scratch.
* **Boundary-aware**, compaction boundaries are shifted to avoid splitting tool call / tool result pairs.
* **Configurable**, `protectHead` preserves the first N messages (usually the system context), and `tailTokenBudget` keeps the most recent messages intact.

* [  JavaScript ](#tab-panel-5619)
* [  TypeScript ](#tab-panel-5620)

JavaScript

```
import { createCompactFunction } from "agents/experimental/memory/utils/compaction-helpers";
const session = Session.create(this)  .withContext("memory", { maxTokens: 1100 })  .onCompaction(    createCompactFunction({      summarize: (prompt) =>        generateText({ model: myModel, prompt }).then((r) => r.text),      protectHead: 3,      tailTokenBudget: 20000,      minTailMessages: 2,    }),  )  .compactAfter(100_000); // Auto-compact when token estimate exceeds threshold
```

TypeScript

```
import { createCompactFunction } from "agents/experimental/memory/utils/compaction-helpers";
const session = Session.create(this)  .withContext("memory", { maxTokens: 1100 })  .onCompaction(    createCompactFunction({      summarize: (prompt) =>        generateText({ model: myModel, prompt }).then((r) => r.text),      protectHead: 3,      tailTokenBudget: 20000,      minTailMessages: 2,    }),  )  .compactAfter(100_000); // Auto-compact when token estimate exceeds threshold
```

Auto-compaction triggers after `appendMessage()` when the estimated token count exceeds the threshold. Compaction failure is non-fatal, the message is already saved.

#### Micro-compaction

Micro-compaction works at the individual message level rather than across ranges. It handles two problems:

**Read-time truncation**: `truncateOlderMessages()` shortens tool outputs and long text in older messages before sending them to the LLM. Recent messages (last 4 by default) are kept intact. This operates on a copy, stored messages are not mutated.

* [  JavaScript ](#tab-panel-5613)
* [  TypeScript ](#tab-panel-5614)

JavaScript

```
import { truncateOlderMessages } from "agents/experimental/memory/utils";
const history = await session.getHistory();const truncated = truncateOlderMessages(history);// Pass truncated history to the LLM
```

TypeScript

```
import { truncateOlderMessages } from "agents/experimental/memory/utils";
const history = await session.getHistory();const truncated = truncateOlderMessages(history);// Pass truncated history to the LLM
```

**Row size enforcement**: when a message is persisted (typically an assistant message with large tool outputs), it is checked against the SQLite row size limit. Oversized tool outputs are replaced with a preview and a note suggesting the tool be re-run. This prevents individual messages from exceeding storage limits while preserving the conversation flow.

## Related

[ Session API reference ](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/) Full API reference for Session, covering messages, context blocks, compaction, search, tools, and custom providers. 

[ Store and sync state ](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) setState() for simpler key-value persistence and real-time sync. 

[ Think ](https://developers.cloudflare.com/agents/harnesses/think/) Opinionated chat agent with built-in Session integration via configureSession().

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/concepts/conversation-state-and-memory/#page","headline":"Conversation state and memory · Cloudflare Agents docs","description":"How agents store and recall information, including read-only context, writable short-form memory, searchable knowledge, and on-demand skills.","url":"https://developers.cloudflare.com/agents/concepts/conversation-state-and-memory/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/concepts/","name":"Concepts"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/concepts/conversation-state-and-memory/","name":"Conversation state and memory"}}]}
```

---

---
title: Tools
description: Understand how tools are exposed to models, where tools execute, and how MCP connects external tools to Cloudflare Agents.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Tools

Tools let models retrieve information, process data, and perform actions. Each tool defines an interface that describes its inputs, outputs, and behavior.

A travel agent might use tools to search flights, check hotel rates, process payments, and send confirmation emails. The tool interface lets the model understand when and how to use each capability.

Tool design involves three independent choices:

1. **Model interface** — expose tools as direct tool calls or through Code Mode.
2. **Execution location** — run tool implementations in a Worker, browser, or another Agent.
3. **Tool source** — define tools in the application or connect to externally hosted tools through Model Context Protocol (MCP).

For example, browser tools can be exposed directly or through Code Mode. An MCP tool can also be exposed through either model interface.

## Choose the model interface

Direct tool calls and Code Mode define how the model sees and invokes tools. They do not determine where the underlying tool implementations run.

| Interface         | How it works                                                                                                      | Use when                                                                                                |
| ----------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Direct tool calls | The model receives individual tool definitions. Each result returns to the model before it chooses the next call. | The task is simple and uses a small, known tool set.                                                    |
| Code Mode         | The model receives one code tool and writes code against typed tool interfaces.                                   | The task needs composition, dependent calls, filtering, branching, repeatable logic, or tool discovery. |

### Direct tool calls

Most tool examples use direct tool calls, even when they do not name the pattern. You define each tool and pass its schema to the model. The model selects a tool, your application executes it, and the result returns to the model.

The model sees each intermediate result before choosing another tool. This makes the execution path easy to inspect. However, dependent operations require repeated model turns and consume context with intermediate data.

### Code Mode

[Code Mode](https://developers.cloudflare.com/agents/tools/codemode/) gives the model one code-execution tool. The model writes JavaScript that calls typed tools, passes results between them, and applies control flow.

The generated code can filter large responses and return only the final value the model needs. Intermediate results stay inside the sandbox instead of entering the model context after every operation. This makes Code Mode more efficient for composed workflows and large tool catalogs.

Code Mode also supports progressive discovery. The model can search available connectors and request detailed types only for the methods it needs. Successful programs can be saved as reusable snippets.

For runtime behavior, approvals, replay, and snippets, refer to [How Code Mode works](https://developers.cloudflare.com/agents/tools/codemode/how-it-works/).

## Choose where tools run

Execution location describes where a tool implementation runs. It is independent from the model interface. Tools in each location can be exposed directly or through Code Mode.

| Location      | Use when                                                                      | Start here                                                                                                               |
| ------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Worker        | The tool calls an API, queries SQL, or uses server-side bindings and secrets. | [Server-side tools](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/#server-side-tools) |
| Browser       | The tool needs geolocation, clipboard, local storage, or other browser APIs.  | [Client-side tools](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/#client-side-tools) |
| Another Agent | A chat-capable Agent should execute as a retained, streaming tool.            | [Agents as tools](https://developers.cloudflare.com/agents/runtime/execution/agent-tools/)                               |

With direct tool calls, the model calls the tool and the framework routes execution to the configured location.

With Code Mode, generated code runs in a sandbox. Calls from that code cross the sandbox boundary to the underlying tool implementation. Server tools can execute in the host Worker, browser-owned tools can execute in the parent page, and Agent tools can delegate work to another Agent.

## Connect external tools with MCP

The [Model Context Protocol (MCP) ↗](https://modelcontextprotocol.io/introduction) standardizes how AI applications discover and invoke externally hosted tools. MCP describes the source and transport of a tool, not how the model must invoke it.

An Agent can expose MCP tools through either model interface:

* Pass MCP tools directly to a model with the [Agents MCP client](https://developers.cloudflare.com/agents/tools/mcp/).
* Expose MCP tools inside Code Mode for composition and progressive discovery with [MCP connectors](https://developers.cloudflare.com/agents/tools/codemode/mcp/).

An MCP server can also expose Code Mode itself. For example, it can present one `code` tool or separate `search` and `execute` tools. For these server-side patterns, refer to [Code Mode MCP server patterns](https://developers.cloudflare.com/agents/model-context-protocol/codemode/).

## Control side effects

Approval policy is also independent from execution location and tool source. Any tool that modifies external state may require user approval.

Direct tools can use the standard [human-in-the-loop approval pattern](https://developers.cloudflare.com/agents/concepts/agentic-patterns/human-in-the-loop/).

The durable Code Mode runtime can pause generated code before an annotated connector method executes. Approval replays completed calls from the execution log, applies the approved action, and continues the same program.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/concepts/tools/#page","headline":"Tools · Cloudflare Agents docs","description":"Understand how tools are exposed to models, where tools execute, and how MCP connects external tools to Cloudflare Agents.","url":"https://developers.cloudflare.com/agents/concepts/tools/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-24","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/concepts/","name":"Concepts"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/concepts/tools/","name":"Tools"}}]}
```

---

---
title: What are agents?
description: Understand what Agents are, how they differ from workflows and co-pilots, and when to use them.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# What are agents?

An agent is an AI system that can autonomously execute tasks by making decisions about tool usage and process flow. Unlike traditional automation that follows predefined paths, agents can dynamically adapt their approach based on context and intermediate results. Agents are also distinct from co-pilots (such as traditional chat applications) in that they can fully automate a task, as opposed to simply augmenting and extending human input.

* **Agents** → non-linear, non-deterministic (can change from run to run)
* **Workflows** → linear, deterministic execution paths
* **Co-pilots** → augmentative AI assistance requiring human intervention

## Example: Booking vacations

If this is your first time working with or interacting with agents, this example illustrates how an agent works within a context like booking a vacation.

Imagine you are trying to book a vacation. You need to research flights, find hotels, check restaurant reviews, and keep track of your budget.

### Traditional workflow automation

A traditional automation system follows a predetermined sequence:

* Takes specific inputs (dates, location, budget)
* Calls predefined API endpoints in a fixed order
* Returns results based on hardcoded criteria
* Cannot adapt if unexpected situations arise
![Traditional workflow automation diagram](https://developers.cloudflare.com/_astro/workflow-automation.D1rsykgR_Z1dw1Js.svg) 

### AI Co-pilot

A co-pilot acts as an intelligent assistant that:

* Provides hotel and itinerary recommendations based on your preferences
* Can understand and respond to natural language queries
* Offers guidance and suggestions
* Requires human decision-making and action for execution
![A co-pilot diagram](https://developers.cloudflare.com/_astro/co-pilot.BZ_kRuK6_Z2sKyKr.svg) 

### Agent

An agent combines AI's ability to make judgments and call the relevant tools to execute the task. An agent's output will be nondeterministic given:

* Real-time availability and pricing changes
* Dynamic prioritization of constraints
* Ability to recover from failures
* Adaptive decision-making based on intermediate results
![An agent diagram](https://developers.cloudflare.com/_astro/agent-workflow.5VDKtHdO_Z1Hdwi1.svg) 

An agent can dynamically generate an itinerary and execute on booking reservations, similarly to what you would expect from a travel agent.

## Components of agent systems

Agent systems typically have three primary components:

* **Decision Engine**: Usually an LLM (Large Language Model) that determines action steps
* **Tool Integration**: APIs, functions, and services the agent can utilize — often via [MCP](https://developers.cloudflare.com/agents/model-context-protocol/)
* **Memory System**: Maintains context and tracks task progress

### How agents work

Agents operate in a continuous loop of:

1. **Observing** the current state or task
2. **Planning** what actions to take, using AI for reasoning
3. **Executing** those actions using available tools
4. **Learning** from the results (storing results in memory, updating task progress, and preparing for next iteration)

## Building agents on Cloudflare

The Cloudflare Agents SDK provides the infrastructure for building production agents:

* **Persistent state** — Each agent instance has its own SQLite database for storing context and memory
* **Real-time sync** — State changes automatically broadcast to all connected clients via WebSockets
* **Hibernation** — Agents sleep when idle and wake on demand, so you only pay for what you use
* **Global edge deployment** — Agents run close to your users on Cloudflare's network
* **Built-in capabilities** — Scheduling, task queues, workflows, email handling, and more

## Next steps

[ Quick start ](https://developers.cloudflare.com/agents/getting-started/quick-start/) Build your first agent in 10 minutes. 

[ Agents API ](https://developers.cloudflare.com/agents/runtime/agents-api/) Complete API reference for the Agents SDK. 

[ Using AI models ](https://developers.cloudflare.com/agents/runtime/operations/using-ai-models/) Integrate OpenAI, Anthropic, and other providers.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/concepts/what-are-agents/#page","headline":"What are agents? · Cloudflare Agents docs","description":"Understand what Agents are, how they differ from workflows and co-pilots, and when to use them.","url":"https://developers.cloudflare.com/agents/concepts/what-are-agents/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI","LLM"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/concepts/","name":"Concepts"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/concepts/what-are-agents/","name":"What are agents?"}}]}
```

---

---
title: Using Agents with Workflows
description: Integrate Cloudflare Workflows with Agents for durable, multi-step background processing with automatic retries.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Using Agents with Workflows

## What are Workflows?

[Cloudflare Workflows](https://developers.cloudflare.com/workflows/) provide durable, multi-step execution for tasks that need to survive failures, retry automatically, and wait for external events. When integrated with Agents, Workflows handle long-running background processing while Agents manage real-time communication.

### Agents vs. Workflows

Agents and Workflows have complementary strengths:

| Capability              | Agents                                   | Workflows                      |
| ----------------------- | ---------------------------------------- | ------------------------------ |
| Execution model         | Long-lived identity that wakes on events | Run to completion              |
| Real-time communication | WebSockets, HTTP streaming               | Not supported                  |
| State persistence       | Built-in SQL database                    | Step-level persistence         |
| Failure handling        | Application-defined                      | Automatic retries and recovery |
| External events         | Direct handling                          | Pause and wait for events      |
| User interaction        | Direct (chat, UI)                        | Through Agent callbacks        |

Agents can loop, branch, and interact directly with users. Workflows execute steps sequentially with guaranteed delivery and can pause for days waiting for approvals or external data.

### When to use each

**Use Agents alone for:**

* Chat and messaging applications
* Quick API calls and responses
* Real-time collaborative features
* Tasks under 30 seconds
* One durable Think chat turn with [submitMessages()](https://developers.cloudflare.com/agents/harnesses/think/programmatic-submissions/#submitmessages) **Use Agents with Workflows for:**
* Data processing pipelines
* Report generation
* Human-in-the-loop approval flows
* Tasks requiring guaranteed delivery
* Multi-step operations with retry requirements

**Use Workflows alone for:**

* Background jobs with or without user approval
* Scheduled data synchronization
* Event-driven processing pipelines

## How Agents and Workflows communicate

The `AgentWorkflow` class (imported from `agents/workflows`) provides bidirectional communication between Workflows and their originating Agent.

### Workflow to Agent

Workflows can communicate with Agents through several mechanisms:

* **RPC calls**: Directly call Agent methods with full type safety via `this.agent`
* **Progress reporting**: Send progress updates via `this.reportProgress()` that trigger Agent callbacks
* **State updates**: Modify Agent state via `step.updateAgentState()` or `step.mergeAgentState()`, which broadcasts to connected clients
* **Client broadcasts**: Send messages to all WebSocket clients via `this.broadcastToClients()`

* [  JavaScript ](#tab-panel-5623)
* [  TypeScript ](#tab-panel-5624)

JavaScript

```
// Inside a workflow's run() methodawait this.agent.updateTaskStatus(taskId, "processing"); // RPC callawait this.reportProgress({ step: "process", percent: 0.5 }); // Progress (non-durable)this.broadcastToClients({ type: "update", taskId }); // Broadcast (non-durable)await step.mergeAgentState({ taskProgress: 0.5 }); // State update (durable)
```

TypeScript

```
// Inside a workflow's run() methodawait this.agent.updateTaskStatus(taskId, "processing"); // RPC callawait this.reportProgress({ step: "process", percent: 0.5 }); // Progress (non-durable)this.broadcastToClients({ type: "update", taskId }); // Broadcast (non-durable)await step.mergeAgentState({ taskProgress: 0.5 }); // State update (durable)
```

### Agent to Workflow

Agents can interact with running Workflows by:

* **Starting workflows**: Launch new workflow instances with `runWorkflow()`
* **Sending events**: Dispatch events with `sendWorkflowEvent()`
* **Approval/rejection**: Respond to approval requests with `approveWorkflow()` / `rejectWorkflow()`
* **Workflow control**: Pause, resume, terminate, or restart workflows
* **Status queries**: Check workflow progress with `getWorkflow()` / `getWorkflows()`

## Durable vs. non-durable operations

Understanding durability is key to using workflows effectively:

### Non-durable (may repeat on retry)

These operations are lightweight and suitable for frequent updates, but may execute multiple times if the workflow retries:

* `this.reportProgress()` — Progress reporting
* `this.broadcastToClients()` — WebSocket broadcasts
* Direct RPC calls to `this.agent`

### Durable (idempotent, won't repeat)

These operations use the `step` parameter and are guaranteed to execute exactly once:

* `step.do()` — Execute durable steps
* `step.reportComplete()` / `step.reportError()` — Completion reporting
* `step.sendEvent()` — Custom events
* `step.updateAgentState()` / `step.mergeAgentState()` — State synchronization

## Durability guarantees

Workflows provide durability through step-based execution:

1. **Step completion is permanent** — Once a step completes, it will not re-execute even if the workflow restarts
2. **Automatic retries** — Failed steps retry with configurable backoff
3. **Event persistence** — Workflows can wait for events for up to one year
4. **State recovery** — Workflow state survives infrastructure failures

This durability model means workflows are well-suited for tasks where partial completion must be preserved, such as multi-stage data processing or transactions spanning multiple systems.

## Workflow tracking

When an Agent starts a workflow using `runWorkflow()`, the workflow is automatically tracked in the Agent's internal database. This enables:

* Querying workflow status by ID, name, or metadata with cursor-based pagination
* Monitoring progress through lifecycle callbacks (`onWorkflowProgress`, `onWorkflowComplete`, `onWorkflowError`)
* Workflow control: pause, resume, terminate, restart
* Cleaning up completed workflow records with `deleteWorkflow()` / `deleteWorkflows()`
* Correlating workflows with users or sessions through metadata

## Common patterns

### Background processing with progress

An Agent receives a request, starts a Workflow for heavy processing, and broadcasts progress updates to connected clients as the Workflow executes each step.

* [  JavaScript ](#tab-panel-5625)
* [  TypeScript ](#tab-panel-5626)

JavaScript

```
// Workflow reports progress after each itemfor (let i = 0; i < items.length; i++) {  await step.do(`process-${i}`, async () => processItem(items[i]));  await this.reportProgress({    step: `process-${i}`,    percent: (i + 1) / items.length,    message: `Processed ${i + 1}/${items.length}`,  });}
```

TypeScript

```
// Workflow reports progress after each itemfor (let i = 0; i < items.length; i++) {  await step.do(`process-${i}`, async () => processItem(items[i]));  await this.reportProgress({    step: `process-${i}`,    percent: (i + 1) / items.length,    message: `Processed ${i + 1}/${items.length}`,  });}
```

### Human-in-the-loop approval

A Workflow prepares a request, pauses to wait for approval using `waitForApproval()`, and the Agent provides UI for users to approve or reject via `approveWorkflow()` / `rejectWorkflow()`. The Workflow resumes or throws `WorkflowRejectedError` based on the decision.

### Resilient external API calls

A Workflow wraps external API calls in durable steps with retry logic. If the API fails or the workflow restarts, completed calls are not repeated and failed calls retry automatically.

* [  JavaScript ](#tab-panel-5627)
* [  TypeScript ](#tab-panel-5628)

JavaScript

```
const result = await step.do(  "call-api",  {    retries: { limit: 5, delay: "10 seconds", backoff: "exponential" },    timeout: "5 minutes",  },  async () => {    const response = await fetch("https://api.example.com/process");    if (!response.ok) throw new Error(`API error: ${response.status}`);    return response.json();  },);
```

TypeScript

```
const result = await step.do(  "call-api",  {    retries: { limit: 5, delay: "10 seconds", backoff: "exponential" },    timeout: "5 minutes",  },  async () => {    const response = await fetch("https://api.example.com/process");    if (!response.ok) throw new Error(`API error: ${response.status}`);    return response.json();  },);
```

### State synchronization

A Workflow updates Agent state at key milestones using `step.updateAgentState()` or `step.mergeAgentState()`. These state changes broadcast to all connected clients, keeping UIs synchronized without polling.

## Related resources

[ Run Workflows API ](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/) Implementation details for agent workflows. 

[ Cloudflare Workflows ](https://developers.cloudflare.com/workflows/) Workflow fundamentals and documentation. 

[ Human-in-the-loop ](https://developers.cloudflare.com/agents/concepts/agentic-patterns/human-in-the-loop/) Approval flows and manual intervention.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/concepts/workflows/#page","headline":"Using Agents with Workflows · Cloudflare Agents docs","description":"Integrate Cloudflare Workflows with Agents for durable, multi-step background processing with automatic retries.","url":"https://developers.cloudflare.com/agents/concepts/workflows/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/concepts/","name":"Concepts"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/concepts/workflows/","name":"Using Agents with Workflows"}}]}
```

---

---
title: Browser agent
description: Build an agent that uses Browser Run tools to inspect pages, capture screenshots, scrape rendered content, and debug frontend issues.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Browser agent

Build an agent that can browse the web, inspect pages, capture screenshots, and debug frontend issues with [Browser Run](https://developers.cloudflare.com/browser-run/) tools. Beta

Instead of a fixed set of browser actions (click, screenshot, navigate), the LLM writes JavaScript code that runs CDP commands against a live browser session — accessing all domains, commands, events, and types in the protocol.

Two tools are provided:

| Tool             | Description                                                                                                                             |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| browser\_search  | Query the CDP spec to discover commands, events, and types. The spec is fetched dynamically from the browser's CDP endpoint and cached. |
| browser\_execute | Run CDP commands against a live browser via a cdp helper. Each call opens a fresh browser session, executes the code, and closes it.    |

## When to use browser tools

Browser tools are useful when your agent needs to:

* **Inspect web pages** — DOM structure, computed styles, accessibility tree
* **Debug frontend issues** — network waterfalls, console errors, performance traces
* **Scrape structured data** — extract content from rendered pages
* **Capture screenshots or PDFs** — visual snapshots of web content
* **Profile performance** — Core Web Vitals, JavaScript profiling, memory analysis

For basic page fetches that do not need a rendered DOM, use `fetch()` instead.

## Install

Browser tools require the Agents SDK and `@cloudflare/codemode`:

Terminal window

```
npm install agents @cloudflare/codemode ai zod
```

## Quick start

### 1\. Configure bindings

Add the Browser Run (formerly Browser Rendering) and Worker Loader bindings to your wrangler configuration:

* [  wrangler.jsonc ](#tab-panel-5629)
* [  wrangler.toml ](#tab-panel-5630)

JSONC

```
{  "compatibility_flags": ["nodejs_compat"],  "browser": {    "binding": "BROWSER",  },  "worker_loaders": [    {      "binding": "LOADER",    },  ],}
```

TOML

```
compatibility_flags = [ "nodejs_compat" ]
[browser]binding = "BROWSER"
[[worker_loaders]]binding = "LOADER"
```

### 2\. Create browser tools

* [  JavaScript ](#tab-panel-5633)
* [  TypeScript ](#tab-panel-5634)

JavaScript

```
import { createBrowserTools } from "agents/browser/ai";
const browserTools = createBrowserTools({  browser: env.BROWSER,  loader: env.LOADER,});
```

TypeScript

```
import { createBrowserTools } from "agents/browser/ai";
const browserTools = createBrowserTools({  browser: env.BROWSER,  loader: env.LOADER,});
```

To connect to a custom CDP endpoint instead of the Browser Run binding, pass `cdpUrl`.

### 3\. Use with streamText

Pass browser tools alongside your other tools. The `model` can be any AI SDK provider — here using Workers AI:

* [  JavaScript ](#tab-panel-5635)
* [  TypeScript ](#tab-panel-5636)

JavaScript

```
import { streamText } from "ai";import { createWorkersAI } from "workers-ai-provider";
const workersai = createWorkersAI({ binding: env.AI });
const result = streamText({  model: workersai("@cf/zai-org/glm-4.7-flash"),  system: "You are a helpful assistant that can inspect web pages.",  messages,  tools: {    ...browserTools,    ...otherTools,  },});
```

TypeScript

```
import { streamText } from "ai";import { createWorkersAI } from "workers-ai-provider";
const workersai = createWorkersAI({ binding: env.AI });
const result = streamText({  model: workersai("@cf/zai-org/glm-4.7-flash"),  system: "You are a helpful assistant that can inspect web pages.",  messages,  tools: {    ...browserTools,    ...otherTools,  },});
```

Both tools accept a `code` parameter containing a JavaScript async arrow function. The sandbox injects globals depending on the tool — `spec` for `browser_search` and `cdp` for `browser_execute`.

When the LLM uses `browser_search`, the code queries the CDP spec via the injected `spec` object:

JavaScript

```
async () => {  const s = await spec.get();  return s.domains    .find((d) => d.name === "Network")    .commands.map((c) => ({ method: c.method, description: c.description }));};
```

When the LLM uses `browser_execute`, the code runs CDP commands via the injected `cdp` helper:

JavaScript

```
async () => {  const { targetId } = await cdp.send("Target.createTarget", {    url: "https://example.com",  });  const sessionId = await cdp.attachToTarget(targetId);  const { root } = await cdp.send("DOM.getDocument", {}, { sessionId });  const { outerHTML } = await cdp.send(    "DOM.getOuterHTML",    { nodeId: root.nodeId },    { sessionId },  );  await cdp.send("Target.closeTarget", { targetId });  return outerHTML;};
```

## Use with an Agent

The typical pattern is to create browser tools inside an [AIChatAgent](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/) message handler, which gives you message persistence and streaming:

* [  JavaScript ](#tab-panel-5641)
* [  TypeScript ](#tab-panel-5642)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { createBrowserTools } from "agents/browser/ai";import { createWorkersAI } from "workers-ai-provider";import { streamText, convertToModelMessages, stepCountIs } from "ai";
export class MyAgent extends AIChatAgent {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });    const browserTools = createBrowserTools({      browser: this.env.BROWSER,      loader: this.env.LOADER,    });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      system: "You can browse the web and inspect pages.",      messages: await convertToModelMessages(this.messages),      tools: {        ...browserTools,      },      stopWhen: stepCountIs(10),    });
    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { createBrowserTools } from "agents/browser/ai";import { createWorkersAI } from "workers-ai-provider";import { streamText, convertToModelMessages, stepCountIs } from "ai";
export class MyAgent extends AIChatAgent<Env> {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });    const browserTools = createBrowserTools({      browser: this.env.BROWSER,      loader: this.env.LOADER,    });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      system: "You can browse the web and inspect pages.",      messages: await convertToModelMessages(this.messages),      tools: {        ...browserTools,      },      stopWhen: stepCountIs(10),    });
    return result.toUIMessageStreamResponse();  }}
```

## TanStack AI

For TanStack AI, use the `/tanstack-ai` export:

* [  JavaScript ](#tab-panel-5637)
* [  TypeScript ](#tab-panel-5638)

JavaScript

```
import { createBrowserTools } from "agents/browser/tanstack-ai";import { chat, workersAIText } from "@tanstack/ai";
const browserTools = createBrowserTools({  browser: env.BROWSER,  loader: env.LOADER,});
const stream = chat({  adapter: workersAIText(env.AI, "@cf/zai-org/glm-4.7-flash"),  tools: [...browserTools, ...otherTools],  messages,});
```

TypeScript

```
import { createBrowserTools } from "agents/browser/tanstack-ai";import { chat, workersAIText } from "@tanstack/ai";
const browserTools = createBrowserTools({  browser: env.BROWSER,  loader: env.LOADER,});
const stream = chat({  adapter: workersAIText(env.AI, "@cf/zai-org/glm-4.7-flash"),  tools: [...browserTools, ...otherTools],  messages,});
```

## Execution model

* `browser_search` fetches the live CDP protocol from the browser's `/json/protocol` endpoint and caches it briefly.
* `browser_execute` opens a fresh browser session for each call, exposes a small `cdp` helper API to sandboxed code, and closes the session when execution finishes.
* LLM-generated code runs in a Worker sandbox. CDP traffic stays in the host Worker.

## CDP helper API

Inside `browser_execute`, the following functions are available to the sandboxed code.

### `cdp.send(method, params?, options?)`

Send a CDP command and wait for the response.

| Parameter         | Type    | Description                                                   |
| ----------------- | ------- | ------------------------------------------------------------- |
| method            | string  | CDP method, for example "DOM.getDocument" or "Network.enable" |
| params            | unknown | Method parameters                                             |
| options.timeoutMs | number  | Per-command timeout (default: 10 seconds)                     |
| options.sessionId | string  | Target session ID (required for page-scoped commands)         |

### `cdp.attachToTarget(targetId, options?)`

Attach to a target and get a session ID. Uses `Target.attachToTarget` with `flatten: true`.

| Parameter         | Type   | Description                    |
| ----------------- | ------ | ------------------------------ |
| targetId          | string | The target to attach to        |
| options.timeoutMs | number | Timeout for the attach command |

Returns the `sessionId` string.

### `cdp.getDebugLog(limit?)`

Get recent CDP debug log entries (sends, receives, errors). Defaults to the last 50 entries, max 400.

### `cdp.clearDebugLog()`

Clear the debug log buffer.

## Configuration

### `createBrowserTools(options)`

Returns AI SDK tools (`browser_search` and `browser_execute`).

| Option     | Type                   | Default  | Description                                                    |
| ---------- | ---------------------- | -------- | -------------------------------------------------------------- |
| browser    | Fetcher                | —        | Browser Run binding                                            |
| cdpUrl     | string                 | —        | Optional override for a custom CDP endpoint                    |
| cdpHeaders | Record<string, string> | —        | Headers for CDP URL discovery (for example, Cloudflare Access) |
| loader     | WorkerLoader           | required | Worker Loader binding for sandboxed execution                  |
| timeout    | number                 | 30000    | Execution timeout in milliseconds                              |

Either `browser` or `cdpUrl` must be provided. When both are set, `cdpUrl` takes priority.

### Raw access

For custom integrations, import the building blocks directly:

* [  JavaScript ](#tab-panel-5639)
* [  TypeScript ](#tab-panel-5640)

JavaScript

```
import {  CdpSession,  connectBrowser,  connectUrl,  createBrowserToolHandlers,} from "agents/browser";
// Connect to a custom CDP endpointconst session = await connectUrl("http://localhost:9222");const version = await session.send("Browser.getVersion");session.close();
```

TypeScript

```
import {  CdpSession,  connectBrowser,  connectUrl,  createBrowserToolHandlers,} from "agents/browser";
// Connect to a custom CDP endpointconst session = await connectUrl("http://localhost:9222");const version = await session.send("Browser.getVersion");session.close();
```

## Local development

Recent Wrangler releases support Browser Run in local development. `npx wrangler dev` provisions the browser automatically, so the same `browser: env.BROWSER` setup works locally and when deployed.

Use `cdpUrl` only when you intentionally want to connect to some other CDP-compatible browser endpoint, such as a tunnel or a manually managed Chrome instance.

## Security considerations

* LLM-generated code runs in **isolated Worker sandboxes** — each execution gets its own Worker instance
* External network access (`fetch`, `connect`) is **blocked** in the sandbox at the runtime level
* CDP commands are dispatched via Workers RPC — the WebSocket lives in the host, not the sandbox
* The CDP spec stays on the server — only query results flow to the LLM
* Responses are truncated to approximately 6,000 tokens to prevent context window overflow

## Current limitations

* **One session per execute call** — each `browser_execute` invocation opens a fresh browser session. Multi-step workflows must be completed within a single code block.
* **No authenticated sessions** — the browser starts without any cookies or login state.
* Requires `@cloudflare/codemode` as a peer dependency.
* Limited to JavaScript execution in the sandbox (no TypeScript syntax).

---

## Using Puppeteer directly

If you prefer to control the browser programmatically without LLM-generated code, you can use Puppeteer with the [Browser Run](https://developers.cloudflare.com/browser-run/) API directly.

 npm  yarn  pnpm  bun 

```
npm i -D @cloudflare/puppeteer
```

```
yarn add -D @cloudflare/puppeteer
```

```
pnpm add -D @cloudflare/puppeteer
```

```
bun add -d @cloudflare/puppeteer
```

* [  JavaScript ](#tab-panel-5645)
* [  TypeScript ](#tab-panel-5646)

JavaScript

```
import puppeteer from "@cloudflare/puppeteer";
export class MyAgent extends Agent {  async browse(browserInstance, urls) {    let responses = [];    for (const url of urls) {      const browser = await puppeteer.launch(browserInstance);      const page = await browser.newPage();      await page.goto(url);
      await page.waitForSelector("body");      const bodyContent = await page.$eval(        "body",        (element) => element.innerHTML,      );
      let resp = await this.env.AI.run("@cf/zai-org/glm-4.7-flash", {        messages: [          {            role: "user",            content: `Return a JSON object with the product names, prices and URLs from the website content below. <content>${bodyContent}</content>`,          },        ],      });
      responses.push(resp);      await browser.close();    }
    return responses;  }}
```

TypeScript

```
import puppeteer from "@cloudflare/puppeteer";
interface Env {  BROWSER: Fetcher;  AI: Ai;}
export class MyAgent extends Agent<Env> {  async browse(browserInstance: Fetcher, urls: string[]) {    let responses = [];    for (const url of urls) {      const browser = await puppeteer.launch(browserInstance);      const page = await browser.newPage();      await page.goto(url);
      await page.waitForSelector("body");      const bodyContent = await page.$eval(        "body",        (element) => element.innerHTML,      );
      let resp = await this.env.AI.run("@cf/zai-org/glm-4.7-flash", {        messages: [          {            role: "user",            content: `Return a JSON object with the product names, prices and URLs from the website content below. <content>${bodyContent}</content>`,          },        ],      });
      responses.push(resp);      await browser.close();    }
    return responses;  }}
```

Add the browser binding to your wrangler configuration:

* [  wrangler.jsonc ](#tab-panel-5631)
* [  wrangler.toml ](#tab-panel-5632)

JSONC

```
{  "ai": {    "binding": "AI",  },  "browser": {    "binding": "BROWSER",  },}
```

TOML

```
[ai]binding = "AI"
[browser]binding = "BROWSER"
```

## Using Browserbase

You can also use [Browserbase ↗](https://docs.browserbase.com/integrations/cloudflare/typescript) by using the Browserbase API directly from within your Agent.

Once you have your [Browserbase API key ↗](https://docs.browserbase.com/integrations/cloudflare/typescript), you can add it to your Agent by creating a [secret](https://developers.cloudflare.com/workers/configuration/secrets/):

Terminal window

```
cd your-agent-project-foldernpx wrangler@latest secret put BROWSERBASE_API_KEY
```

Install the `@cloudflare/puppeteer` package and use it from within your Agent to call the Browserbase API:

 npm  yarn  pnpm  bun 

```
npm i @cloudflare/puppeteer
```

```
yarn add @cloudflare/puppeteer
```

```
pnpm add @cloudflare/puppeteer
```

```
bun add @cloudflare/puppeteer
```

* [  JavaScript ](#tab-panel-5643)
* [  TypeScript ](#tab-panel-5644)

JavaScript

```
import puppeteer from "@cloudflare/puppeteer";
export class MyAgent extends Agent {  async browse(url) {    const browser = await puppeteer.connect({      browserWSEndpoint: `wss://connect.browserbase.com?apiKey=${this.env.BROWSERBASE_API_KEY}`,    });    const page = await browser.newPage();    await page.goto(url);    const content = await page.content();    await browser.close();    return content;  }}
```

TypeScript

```
import puppeteer from "@cloudflare/puppeteer";
interface Env {  BROWSERBASE_API_KEY: string;}
export class MyAgent extends Agent<Env> {  async browse(url: string) {    const browser = await puppeteer.connect({      browserWSEndpoint: `wss://connect.browserbase.com?apiKey=${this.env.BROWSERBASE_API_KEY}`,    });    const page = await browser.newPage();    await page.goto(url);    const content = await page.content();    await browser.close();    return content;  }}
```

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/examples/browser-agent/#page","headline":"Browser agent · Cloudflare Agents docs","description":"Build an agent that uses Browser Run tools to inspect pages, capture screenshots, scrape rendered content, and debug frontend issues.","url":"https://developers.cloudflare.com/agents/examples/browser-agent/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/examples/","name":"Examples"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/examples/browser-agent/","name":"Browser agent"}}]}
```

---

---
title: Chat agent
description: Build a streaming AI chat agent with tools using Workers AI — no API keys required.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Chat agent

Build a chat agent that streams AI responses, calls server-side tools, executes client-side tools in the browser, and asks for user approval before sensitive actions.

**What you will build:** A chat agent powered by Workers AI with three tool types — automatic, client-side, and approval-gated.

**Time:** \~15 minutes

This tutorial starts from a minimal Hello World Worker so you can see each moving part. If you want a complete starter app with the same core pieces already wired together, start with the [quick start](https://developers.cloudflare.com/agents/getting-started/quick-start/) and then return here to understand how the chat pieces fit together.

**Prerequisites:**

* Node.js 18+
* A Cloudflare account (free tier works)

## 1\. Create the project

Terminal window

```
npm create cloudflare@latest chat-agent
```

Select **"Hello World" Worker** when prompted. Then install the dependencies:

Terminal window

```
cd chat-agentnpm install agents @cloudflare/ai-chat ai workers-ai-provider zod
```

## 2\. Configure Wrangler

Replace your `wrangler.jsonc` with:

* [  wrangler.jsonc ](#tab-panel-5647)
* [  wrangler.toml ](#tab-panel-5648)

JSONC

```
{  "name": "chat-agent",  "main": "src/server.ts",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": ["nodejs_compat"],  "ai": { "binding": "AI" },  "durable_objects": {    "bindings": [{ "name": "ChatAgent", "class_name": "ChatAgent" }],  },  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ChatAgent"] }],}
```

TOML

```
name = "chat-agent"main = "src/server.ts"# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = [ "nodejs_compat" ]
[ai]binding = "AI"
[[durable_objects.bindings]]name = "ChatAgent"class_name = "ChatAgent"
[[migrations]]tag = "v1"new_sqlite_classes = [ "ChatAgent" ]
```

Key settings:

* `ai` binds Workers AI — no API key needed
* `durable_objects` registers your chat agent class
* `new_sqlite_classes` enables SQLite storage for message persistence

## 3\. Write the server

Create `src/server.ts`. This is where your agent lives:

* [  JavaScript ](#tab-panel-5649)
* [  TypeScript ](#tab-panel-5650)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { routeAgentRequest } from "agents";import { createWorkersAI } from "workers-ai-provider";import {  streamText,  convertToModelMessages,  pruneMessages,  tool,  stepCountIs,} from "ai";import { z } from "zod";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/meta/llama-4-scout-17b-16e-instruct"),      system:        "You are a helpful assistant. You can check the weather, " +        "get the user's timezone, and run calculations.",      messages: pruneMessages({        messages: await convertToModelMessages(this.messages),        toolCalls: "before-last-2-messages",      }),      tools: {        // Server-side tool: runs automatically on the server        getWeather: tool({          description: "Get the current weather for a city",          inputSchema: z.object({            city: z.string().describe("City name"),          }),          execute: async ({ city }) => {            // Replace with a real weather API in production            const conditions = ["sunny", "cloudy", "rainy"];            const temp = Math.floor(Math.random() * 30) + 5;            return {              city,              temperature: temp,              condition:                conditions[Math.floor(Math.random() * conditions.length)],            };          },        }),
        // Client-side tool: no execute function — the browser handles it        getUserTimezone: tool({          description: "Get the user's timezone from their browser",          inputSchema: z.object({}),        }),
        // Approval tool: requires user confirmation before executing        calculate: tool({          description:            "Perform a math calculation with two numbers. " +            "Requires user approval for large numbers.",          inputSchema: z.object({            a: z.coerce.number().describe("First number"),            b: z.coerce.number().describe("Second number"),            operator: z              .enum(["+", "-", "*", "/", "%"])              .describe("Arithmetic operator"),          }),          needsApproval: async ({ a, b }) =>            Math.abs(a) > 1000 || Math.abs(b) > 1000,          execute: async ({ a, b, operator }) => {            const ops = {              "+": (x, y) => x + y,              "-": (x, y) => x - y,              "*": (x, y) => x * y,              "/": (x, y) => x / y,              "%": (x, y) => x % y,            };            if (operator === "/" && b === 0) {              return { error: "Division by zero" };            }            return {              expression: `${a} ${operator} ${b}`,              result: ops[operator](a, b),            };          },        }),      },      stopWhen: stepCountIs(5),    });
    return result.toUIMessageStreamResponse();  }}
export default {  async fetch(request, env) {    return (      (await routeAgentRequest(request, env)) ||      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { routeAgentRequest } from "agents";import { createWorkersAI } from "workers-ai-provider";import {  streamText,  convertToModelMessages,  pruneMessages,  tool,  stepCountIs,} from "ai";import { z } from "zod";
export class ChatAgent extends AIChatAgent {  async onChatMessage() {    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/meta/llama-4-scout-17b-16e-instruct"),      system:        "You are a helpful assistant. You can check the weather, " +        "get the user's timezone, and run calculations.",      messages: pruneMessages({        messages: await convertToModelMessages(this.messages),        toolCalls: "before-last-2-messages",      }),      tools: {        // Server-side tool: runs automatically on the server        getWeather: tool({          description: "Get the current weather for a city",          inputSchema: z.object({            city: z.string().describe("City name"),          }),          execute: async ({ city }) => {            // Replace with a real weather API in production            const conditions = ["sunny", "cloudy", "rainy"];            const temp = Math.floor(Math.random() * 30) + 5;            return {              city,              temperature: temp,              condition:                conditions[Math.floor(Math.random() * conditions.length)],            };          },        }),
        // Client-side tool: no execute function — the browser handles it        getUserTimezone: tool({          description: "Get the user's timezone from their browser",          inputSchema: z.object({}),        }),
        // Approval tool: requires user confirmation before executing        calculate: tool({          description:            "Perform a math calculation with two numbers. " +            "Requires user approval for large numbers.",          inputSchema: z.object({            a: z.coerce.number().describe("First number"),            b: z.coerce.number().describe("Second number"),            operator: z              .enum(["+", "-", "*", "/", "%"])              .describe("Arithmetic operator"),          }),          needsApproval: async ({ a, b }) =>            Math.abs(a) > 1000 || Math.abs(b) > 1000,          execute: async ({ a, b, operator }) => {            const ops: Record<string, (x: number, y: number) => number> = {              "+": (x, y) => x + y,              "-": (x, y) => x - y,              "*": (x, y) => x * y,              "/": (x, y) => x / y,              "%": (x, y) => x % y,            };            if (operator === "/" && b === 0) {              return { error: "Division by zero" };            }            return {              expression: `${a} ${operator} ${b}`,              result: ops[operator](a, b),            };          },        }),      },      stopWhen: stepCountIs(5),    });
    return result.toUIMessageStreamResponse();  }}
export default {  async fetch(request: Request, env: Env) {    return (      (await routeAgentRequest(request, env)) ||      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

### What each tool type does

| Tool            | execute? | needsApproval?      | Behavior                                        |
| --------------- | -------- | ------------------- | ----------------------------------------------- |
| getWeather      | Yes      | No                  | Runs on the server automatically                |
| getUserTimezone | No       | No                  | Sent to the client; browser provides the result |
| calculate       | Yes      | Yes (large numbers) | Pauses for user approval, then runs on server   |

## 4\. Write the client

Create `src/client.tsx`:

* [  JavaScript ](#tab-panel-5651)
* [  TypeScript ](#tab-panel-5652)

JavaScript

```
import { useAgent } from "agents/react";import { useAgentChat, getToolApproval } from "@cloudflare/ai-chat/react";
function Chat() {  const agent = useAgent({ agent: "ChatAgent" });
  const {    messages,    sendMessage,    clearHistory,    addToolApprovalResponse,    status,  } = useAgentChat({    agent,    // Handle client-side tools (tools with no server execute function)    onToolCall: async ({ toolCall, addToolOutput }) => {      if (toolCall.toolName === "getUserTimezone") {        addToolOutput({          toolCallId: toolCall.toolCallId,          output: {            timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,            localTime: new Date().toLocaleTimeString(),          },        });      }    },  });
  return (    <div>      <div>        {messages.map((msg) => (          <div key={msg.id}>            <strong>{msg.role}:</strong>            {msg.parts.map((part, i) => {              if (part.type === "text") {                return <span key={i}>{part.text}</span>;              }
              // Render approval UI for tools that need confirmation              if (part.state === "approval-requested") {                const approval = getToolApproval(part);                if (!approval) return null;                return (                  <div key={part.toolCallId}>                    <p>                      Approve <strong>{part.toolName}</strong>?                    </p>                    <pre>{JSON.stringify(part.input, null, 2)}</pre>                    <button                      onClick={() =>                        addToolApprovalResponse({                          id: approval.id,                          approved: true,                        })                      }                    >                      Approve                    </button>                    <button                      onClick={() =>                        addToolApprovalResponse({                          id: approval.id,                          approved: false,                        })                      }                    >                      Reject                    </button>                  </div>                );              }
              // Show completed tool results              if (part.state === "output-available") {                return (                  <details key={part.toolCallId}>                    <summary>{part.toolName} result</summary>                    <pre>{JSON.stringify(part.output, null, 2)}</pre>                  </details>                );              }
              return null;            })}          </div>        ))}      </div>
      <form        onSubmit={(e) => {          e.preventDefault();          const input = e.currentTarget.elements.namedItem("message");          sendMessage({ text: input.value });          input.value = "";        }}      >        <input name="message" placeholder="Try: What's the weather in Paris?" />        <button type="submit" disabled={status === "streaming"}>          Send        </button>      </form>
      <button onClick={clearHistory}>Clear history</button>    </div>  );}
export default function App() {  return <Chat />;}
```

TypeScript

```
import { useAgent } from "agents/react";import { useAgentChat, getToolApproval } from "@cloudflare/ai-chat/react";
function Chat() {  const agent = useAgent({ agent: "ChatAgent" });
  const { messages, sendMessage, clearHistory, addToolApprovalResponse, status } =    useAgentChat({      agent,      // Handle client-side tools (tools with no server execute function)      onToolCall: async ({ toolCall, addToolOutput }) => {        if (toolCall.toolName === "getUserTimezone") {          addToolOutput({            toolCallId: toolCall.toolCallId,            output: {              timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,              localTime: new Date().toLocaleTimeString(),            },          });        }      },    });
  return (    <div>      <div>        {messages.map((msg) => (          <div key={msg.id}>            <strong>{msg.role}:</strong>            {msg.parts.map((part, i) => {              if (part.type === "text") {                return <span key={i}>{part.text}</span>;              }
              // Render approval UI for tools that need confirmation              if (part.state === "approval-requested") {                const approval = getToolApproval(part);                if (!approval) return null;                return (                  <div key={part.toolCallId}>                    <p>                      Approve <strong>{part.toolName}</strong>?                    </p>                    <pre>{JSON.stringify(part.input, null, 2)}</pre>                    <button                      onClick={() =>                        addToolApprovalResponse({                          id: approval.id,                          approved: true,                        })                      }                    >                      Approve                    </button>                    <button                      onClick={() =>                        addToolApprovalResponse({                          id: approval.id,                          approved: false,                        })                      }                    >                      Reject                    </button>                  </div>                );              }
              // Show completed tool results              if (part.state === "output-available") {                return (                  <details key={part.toolCallId}>                    <summary>{part.toolName} result</summary>                    <pre>{JSON.stringify(part.output, null, 2)}</pre>                  </details>                );              }
              return null;            })}          </div>        ))}      </div>
      <form        onSubmit={(e) => {          e.preventDefault();          const input = e.currentTarget.elements.namedItem(            "message",          ) as HTMLInputElement;          sendMessage({ text: input.value });          input.value = "";        }}      >        <input name="message" placeholder="Try: What's the weather in Paris?" />        <button type="submit" disabled={status === "streaming"}>          Send        </button>      </form>
      <button onClick={clearHistory}>Clear history</button>    </div>  );}
export default function App() {  return <Chat />;}
```

### Key client concepts

* **`useAgent`** connects to your `ChatAgent` over WebSocket
* **`useAgentChat`** manages the chat lifecycle (messages, streaming, tools)
* **`onToolCall`** handles client-side tools — when the LLM calls `getUserTimezone`, the browser provides the result and the conversation auto-continues
* **`addToolApprovalResponse`** approves or rejects tools that have `needsApproval`
* Messages, streaming, and resumption are all handled automatically

## 5\. Run locally

Generate types and start the dev server:

Terminal window

```
npx wrangler typesnpm run dev
```

Try these prompts:

* **"What is the weather in Tokyo?"** — calls the server-side `getWeather` tool
* **"What timezone am I in?"** — calls the client-side `getUserTimezone` tool (the browser provides the answer)
* **"What is 5000 times 3?"** — triggers the approval UI before executing (numbers over 1000)

## 6\. Deploy

Terminal window

```
npx wrangler deploy
```

Your agent is now live on Cloudflare's global network. Messages persist in SQLite, streams resume on disconnect, and the agent hibernates when idle to save resources.

## What you built

Your chat agent has:

* **Streaming AI responses** via Workers AI (no API keys)
* **Message persistence** in SQLite — conversations survive restarts
* **Server-side tools** that execute automatically
* **Client-side tools** that run in the browser and feed results back to the LLM
* **Human-in-the-loop approval** for sensitive operations
* **Resumable streaming** — if a client disconnects mid-stream, it picks up where it left off

## Next steps

[ Chat agents API reference ](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/) Full reference for AIChatAgent and useAgentChat — providers, storage, advanced patterns. 

[ Store and sync state ](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) Add real-time state beyond chat messages. 

[ Callable methods ](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/) Expose agent methods as typed RPC for your client. 

[ Human-in-the-loop ](https://developers.cloudflare.com/agents/concepts/agentic-patterns/human-in-the-loop/) Deeper patterns for approval flows and manual intervention.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/examples/chat-agent/#page","headline":"Chat agent · Cloudflare Agents docs","description":"Build a streaming AI chat agent with tools using Workers AI — no API keys required.","url":"https://developers.cloudflare.com/agents/examples/chat-agent/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-09","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/examples/","name":"Examples"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/examples/chat-agent/","name":"Chat agent"}}]}
```

---

---
title: Email agent
description: Build an agent that sends, receives, routes, and replies to email using Cloudflare Email Service and the Agents SDK.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Email agent

Agents can send and receive email with Cloudflare [Email Service](https://developers.cloudflare.com/email-service/api/route-emails/email-handler/). This guide shows how to send outbound email with the Workers binding, route inbound mail into Agents, and handle follow-up replies securely.

## Prerequisites

Before using email with Agents, you need:

1. A domain onboarded to [Cloudflare Email Service](https://developers.cloudflare.com/email-service/).
2. A `send_email` binding in `wrangler.jsonc` for outbound email.
3. An Email Service routing rule that sends inbound mail to your Worker.
4. Optional: an `EMAIL_SECRET` secret if you want secure reply routing.

### Domain setup

1. Log in to the [Cloudflare Dashboard ↗](https://dash.cloudflare.com).
2. Go to **Compute & AI** \> **Email Service**.
3. Select **Onboard Domain** and choose your domain.
4. Add the DNS records (SPF and DKIM) to authorize sending.

DNS changes usually complete within 5-15 minutes for domains using Cloudflare DNS, but can take up to 24 hours to propagate globally.

### Wrangler configuration

Add the email binding to your Worker:

* [  wrangler.jsonc ](#tab-panel-5653)
* [  wrangler.toml ](#tab-panel-5654)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  "send_email": [    {      "name": "EMAIL",      "remote": true    }  ]}
```

TOML

```
[[send_email]]name = "EMAIL"remote = true
```

The `remote = true` option lets you call the real Email Service API during local development with `wrangler dev`.

## Quick start

* [  JavaScript ](#tab-panel-5679)
* [  TypeScript ](#tab-panel-5680)

JavaScript

```
import { Agent, callable, routeAgentEmail } from "agents";import { createAddressBasedEmailResolver } from "agents/email";import PostalMime from "postal-mime";
export class EmailAgent extends Agent {  @callable()  async sendWelcomeEmail(to) {    await this.sendEmail({      binding: this.env.EMAIL,      to,      from: "support@yourdomain.com",      replyTo: "support@yourdomain.com",      subject: "Welcome to our service",      text: "Thanks for signing up. Reply to this email if you need help.",    });  }
  async onEmail(email) {    const raw = await email.getRaw();    const parsed = await PostalMime.parse(raw);
    console.log("Received email from:", email.from);    console.log("Subject:", parsed.subject);
    await this.replyToEmail(email, {      fromName: "Support Agent",      body: "Thanks for your email! We received it.",    });  }}
export default {  async email(message, env) {    await routeAgentEmail(message, env, {      resolver: createAddressBasedEmailResolver("EmailAgent"),    });  },};
```

TypeScript

```
import { Agent, callable, routeAgentEmail } from "agents";import { createAddressBasedEmailResolver, type AgentEmail } from "agents/email";import PostalMime from "postal-mime";
export class EmailAgent extends Agent {  @callable()  async sendWelcomeEmail(to: string) {    await this.sendEmail({      binding: this.env.EMAIL,      to,      from: "support@yourdomain.com",      replyTo: "support@yourdomain.com",      subject: "Welcome to our service",      text: "Thanks for signing up. Reply to this email if you need help.",    });  }
  async onEmail(email: AgentEmail) {    const raw = await email.getRaw();    const parsed = await PostalMime.parse(raw);
    console.log("Received email from:", email.from);    console.log("Subject:", parsed.subject);
    await this.replyToEmail(email, {      fromName: "Support Agent",      body: "Thanks for your email! We received it.",    });  }}
export default {  async email(message, env) {    await routeAgentEmail(message, env, {      resolver: createAddressBasedEmailResolver("EmailAgent"),    });  },} satisfies ExportedHandler<Env>;
```

## Sending outbound email

### Using `sendEmail()`

`sendEmail()` sends outbound email through a `send_email` binding that you pass explicitly. It automatically injects agent routing headers (`X-Agent-Name`, `X-Agent-ID`) into every message, and optionally signs them with HMAC-SHA256 so that replies can be routed back to the same agent instance.

* [  JavaScript ](#tab-panel-5663)
* [  TypeScript ](#tab-panel-5664)

JavaScript

```
class MyAgent extends Agent {  @callable()  async sendReceipt(to, orderId) {    const result = await this.sendEmail({      binding: this.env.EMAIL,      to,      from: { email: "billing@yourdomain.com", name: "Billing Bot" },      replyTo: "billing@yourdomain.com",      subject: `Receipt for order ${orderId}`,      text: `Your receipt for order ${orderId} is ready.`,      secret: this.env.EMAIL_SECRET,    });
    return result.messageId;  }}
```

TypeScript

```
class MyAgent extends Agent {  @callable()  async sendReceipt(to: string, orderId: string) {    const result = await this.sendEmail({      binding: this.env.EMAIL,      to,      from: { email: "billing@yourdomain.com", name: "Billing Bot" },      replyTo: "billing@yourdomain.com",      subject: `Receipt for order ${orderId}`,      text: `Your receipt for order ${orderId} is ready.`,      secret: this.env.EMAIL_SECRET,    });
    return result.messageId;  }}
```

When `secret` is provided, the agent signs the routing headers so that replies verified by `createSecureReplyEmailResolver` route back to the same agent instance.

Set `replyTo` to the mailbox that routes back to your Worker when you want recipients to continue the conversation with the same agent.

## Routing inbound mail

Resolvers determine which Agent instance receives an incoming email. Choose the resolver that matches your use case.

For basic Email Service sending and receiving, `createAddressBasedEmailResolver()` is enough. The secure reply resolver below is optional and specific to Agents SDK reply signing, not a requirement of Email Service itself.

### `createAddressBasedEmailResolver`

Recommended for inbound mail. Routes emails based on the recipient address.

* [  JavaScript ](#tab-panel-5657)
* [  TypeScript ](#tab-panel-5658)

JavaScript

```
import { createAddressBasedEmailResolver } from "agents/email";
const resolver = createAddressBasedEmailResolver("EmailAgent");
```

TypeScript

```
import { createAddressBasedEmailResolver } from "agents/email";
const resolver = createAddressBasedEmailResolver("EmailAgent");
```

**Routing logic:**

| Recipient Address                     | Agent Name           | Agent ID |
| ------------------------------------- | -------------------- | -------- |
| support@example.com                   | EmailAgent (default) | support  |
| sales@example.com                     | EmailAgent (default) | sales    |
| NotificationAgent+user123@example.com | NotificationAgent    | user123  |

The sub-address format (`agent+id@domain`) allows routing to different agent namespaces and instances from a single email domain.

Note

Agent class names in the recipient address are matched case-insensitively. Email infrastructure often lowercases addresses, so `NotificationAgent+user123@example.com` and `notificationagent+user123@example.com` both route to the `NotificationAgent` class.

### `createSecureReplyEmailResolver`

For reply flows with signature verification. Verifies that incoming emails are authentic replies to your outbound emails, preventing attackers from routing emails to arbitrary agent instances.

* [  JavaScript ](#tab-panel-5659)
* [  TypeScript ](#tab-panel-5660)

JavaScript

```
import { createSecureReplyEmailResolver } from "agents/email";
const resolver = createSecureReplyEmailResolver(env.EMAIL_SECRET);
```

TypeScript

```
import { createSecureReplyEmailResolver } from "agents/email";
const resolver = createSecureReplyEmailResolver(env.EMAIL_SECRET);
```

When your agent sends an email with `replyToEmail()` or `sendEmail()` and a `secret`, it signs the routing headers with a timestamp. When a reply comes back, this resolver verifies the signature and checks that it has not expired before routing.

**Options:**

* [  JavaScript ](#tab-panel-5665)
* [  TypeScript ](#tab-panel-5666)

JavaScript

```
const resolver = createSecureReplyEmailResolver(env.EMAIL_SECRET, {  // Maximum age of signature in seconds (default: 30 days)  maxAge: 7 * 24 * 60 * 60, // 7 days
  // Callback for logging/debugging signature failures  onInvalidSignature: (email, reason) => {    console.warn(`Invalid signature from ${email.from}: ${reason}`);    // reason can be: "missing_headers", "expired", "invalid", "malformed_timestamp"  },});
```

TypeScript

```
const resolver = createSecureReplyEmailResolver(env.EMAIL_SECRET, {  // Maximum age of signature in seconds (default: 30 days)  maxAge: 7 * 24 * 60 * 60, // 7 days
  // Callback for logging/debugging signature failures  onInvalidSignature: (email, reason) => {    console.warn(`Invalid signature from ${email.from}: ${reason}`);    // reason can be: "missing_headers", "expired", "invalid", "malformed_timestamp"  },});
```

**When to use:** If your agent initiates email conversations and you need replies to route back to the same agent instance securely.

### `createCatchAllEmailResolver`

For single-instance routing. Routes all emails to a specific agent instance regardless of the recipient address.

* [  JavaScript ](#tab-panel-5661)
* [  TypeScript ](#tab-panel-5662)

JavaScript

```
import { createCatchAllEmailResolver } from "agents/email";
const resolver = createCatchAllEmailResolver("EmailAgent", "default");
```

TypeScript

```
import { createCatchAllEmailResolver } from "agents/email";
const resolver = createCatchAllEmailResolver("EmailAgent", "default");
```

**When to use:** When you have a single agent instance that handles all emails (for example, a shared inbox).

### Combining resolvers

You can combine resolvers to handle different scenarios:

* [  JavaScript ](#tab-panel-5677)
* [  TypeScript ](#tab-panel-5678)

JavaScript

```
export default {  async email(message, env) {    const secureReplyResolver = createSecureReplyEmailResolver(      env.EMAIL_SECRET,    );    const addressResolver = createAddressBasedEmailResolver("EmailAgent");
    await routeAgentEmail(message, env, {      resolver: async (email, env) => {        // First, check if this is a signed reply        const replyRouting = await secureReplyResolver(email, env);        if (replyRouting) return replyRouting;
        // Otherwise, route based on recipient address        return addressResolver(email, env);      },
      // Handle emails that do not match any routing rule      onNoRoute: (email) => {        console.warn(`No route found for email from ${email.from}`);        email.setReject("Unknown recipient");      },    });  },};
```

TypeScript

```
export default {  async email(message, env) {    const secureReplyResolver = createSecureReplyEmailResolver(      env.EMAIL_SECRET,    );    const addressResolver = createAddressBasedEmailResolver("EmailAgent");
    await routeAgentEmail(message, env, {      resolver: async (email, env) => {        // First, check if this is a signed reply        const replyRouting = await secureReplyResolver(email, env);        if (replyRouting) return replyRouting;
        // Otherwise, route based on recipient address        return addressResolver(email, env);      },
      // Handle emails that do not match any routing rule      onNoRoute: (email) => {        console.warn(`No route found for email from ${email.from}`);        email.setReject("Unknown recipient");      },    });  },} satisfies ExportedHandler<Env>;
```

## Handling emails in your Agent

### The `AgentEmail` interface

When your agent's `onEmail` method is called, it receives an `AgentEmail` object:

TypeScript

```
type AgentEmail = {  from: string; // Sender's email address  to: string; // Recipient's email address  headers: Headers; // Email headers (subject, message-id, etc.)  rawSize: number; // Size of the raw email in bytes
  getRaw(): Promise<Uint8Array>; // Get the full raw email content  reply(options): Promise<void>; // Send a reply  forward(rcptTo, headers?): Promise<void>; // Forward the email  setReject(reason): void; // Reject the email with a reason};
```

### Parsing email content

Use a library like [postal-mime ↗](https://www.npmjs.com/package/postal-mime) to parse the raw email:

* [  JavaScript ](#tab-panel-5667)
* [  TypeScript ](#tab-panel-5668)

JavaScript

```
import PostalMime from "postal-mime";
class MyAgent extends Agent {  async onEmail(email) {    const raw = await email.getRaw();    const parsed = await PostalMime.parse(raw);
    console.log("Subject:", parsed.subject);    console.log("Text body:", parsed.text);    console.log("HTML body:", parsed.html);    console.log("Attachments:", parsed.attachments);  }}
```

TypeScript

```
import PostalMime from "postal-mime";
class MyAgent extends Agent {  async onEmail(email: AgentEmail) {    const raw = await email.getRaw();    const parsed = await PostalMime.parse(raw);
    console.log("Subject:", parsed.subject);    console.log("Text body:", parsed.text);    console.log("HTML body:", parsed.html);    console.log("Attachments:", parsed.attachments);  }}
```

### Detecting auto-reply emails

Use `isAutoReplyEmail()` to detect auto-reply emails and avoid mail loops:

* [  JavaScript ](#tab-panel-5671)
* [  TypeScript ](#tab-panel-5672)

JavaScript

```
import { isAutoReplyEmail } from "agents/email";import PostalMime from "postal-mime";
class MyAgent extends Agent {  async onEmail(email) {    const raw = await email.getRaw();    const parsed = await PostalMime.parse(raw);
    // Detect auto-reply emails to avoid sending duplicate responses    if (isAutoReplyEmail(parsed.headers)) {      console.log("Skipping auto-reply email");      return;    }
    // Process the email...  }}
```

TypeScript

```
import { isAutoReplyEmail } from "agents/email";import PostalMime from "postal-mime";
class MyAgent extends Agent {  async onEmail(email: AgentEmail) {    const raw = await email.getRaw();    const parsed = await PostalMime.parse(raw);
    // Detect auto-reply emails to avoid sending duplicate responses    if (isAutoReplyEmail(parsed.headers)) {      console.log("Skipping auto-reply email");      return;    }
    // Process the email...  }}
```

This checks for standard RFC 3834 headers (`Auto-Submitted`, `X-Auto-Response-Suppress`, `Precedence`) that indicate an email is an auto-reply.

### Replying to emails

Use `this.replyToEmail()` to send a reply through the inbound email's reply channel:

* [  JavaScript ](#tab-panel-5673)
* [  TypeScript ](#tab-panel-5674)

JavaScript

```
class MyAgent extends Agent {  async onEmail(email) {    await this.replyToEmail(email, {      fromName: "Support Bot", // Display name for the sender      subject: "Re: Your inquiry", // Optional, defaults to "Re: "      body: "Thanks for contacting us!", // Email body      contentType: "text/plain", // Optional, defaults to "text/plain"      headers: {        // Optional custom headers        "X-Custom-Header": "value",      },      secret: this.env.EMAIL_SECRET, // Optional, signs headers for secure reply routing    });  }}
```

TypeScript

```
class MyAgent extends Agent {  async onEmail(email: AgentEmail) {    await this.replyToEmail(email, {      fromName: "Support Bot", // Display name for the sender      subject: "Re: Your inquiry", // Optional, defaults to "Re: "      body: "Thanks for contacting us!", // Email body      contentType: "text/plain", // Optional, defaults to "text/plain"      headers: {        // Optional custom headers        "X-Custom-Header": "value",      },      secret: this.env.EMAIL_SECRET, // Optional, signs headers for secure reply routing    });  }}
```

### Deferred replies

`replyToEmail()` requires a live `AgentEmail` object, so it only works inside `onEmail()`. If you need to reply later — from a scheduled task, a callable method, or after a human-in-the-loop approval — store the sender info in state and use `sendEmail()`:

* [  JavaScript ](#tab-panel-5685)
* [  TypeScript ](#tab-panel-5686)

JavaScript

```
class MyAgent extends Agent {  async onEmail(email) {    const raw = await email.getRaw();    const parsed = await PostalMime.parse(raw);
    this.setState({      ...this.state,      pendingReply: {        to: email.from,        messageId: parsed.messageId,        subject: parsed.subject,      },    });  }
  @callable()  async sendDelayedReply(body) {    const { pendingReply } = this.state;    if (!pendingReply) return;
    await this.sendEmail({      binding: this.env.EMAIL,      to: pendingReply.to,      from: "support@yourdomain.com",      subject: `Re: ${pendingReply.subject}`,      text: body,      inReplyTo: pendingReply.messageId,      secret: this.env.EMAIL_SECRET,    });  }}
```

TypeScript

```
class MyAgent extends Agent {  async onEmail(email: AgentEmail) {    const raw = await email.getRaw();    const parsed = await PostalMime.parse(raw);
    this.setState({      ...this.state,      pendingReply: {        to: email.from,        messageId: parsed.messageId,        subject: parsed.subject,      },    });  }
  @callable()  async sendDelayedReply(body: string) {    const { pendingReply } = this.state;    if (!pendingReply) return;
    await this.sendEmail({      binding: this.env.EMAIL,      to: pendingReply.to,      from: "support@yourdomain.com",      subject: `Re: ${pendingReply.subject}`,      text: body,      inReplyTo: pendingReply.messageId,      secret: this.env.EMAIL_SECRET,    });  }}
```

The `inReplyTo` field sets the `In-Reply-To` header so mail clients thread the reply correctly. The `secret` signs the agent routing headers so that follow-up replies route back to this agent instance via `createSecureReplyEmailResolver`.

### Forwarding emails

* [  JavaScript ](#tab-panel-5669)
* [  TypeScript ](#tab-panel-5670)

JavaScript

```
class MyAgent extends Agent {  async onEmail(email) {    await email.forward("admin@example.com");  }}
```

TypeScript

```
class MyAgent extends Agent {  async onEmail(email: AgentEmail) {    await email.forward("admin@example.com");  }}
```

### Rejecting emails

* [  JavaScript ](#tab-panel-5675)
* [  TypeScript ](#tab-panel-5676)

JavaScript

```
class MyAgent extends Agent {  async onEmail(email) {    if (isSpam(email)) {      email.setReject("Message rejected as spam");      return;    }    // Process the email...  }}
```

TypeScript

```
class MyAgent extends Agent {  async onEmail(email: AgentEmail) {    if (isSpam(email)) {      email.setReject("Message rejected as spam");      return;    }    // Process the email...  }}
```

## Error handling

When sending emails via `sendEmail()` or `replyToEmail()`, handle these common errors:

* [  JavaScript ](#tab-panel-5687)
* [  TypeScript ](#tab-panel-5688)

JavaScript

```
class MyAgent extends Agent {  async onEmail(email) {    try {      await this.replyToEmail(email, {        fromName: "Support Bot",        body: "Thanks for your email!",      });    } catch (error) {      switch (error.code) {        case "E_SENDER_NOT_VERIFIED":          console.error("Sender domain not verified. Verify in dashboard.");          break;        case "E_RATE_LIMIT_EXCEEDED":          console.error("Rate limit exceeded. Back off and retry.");          break;        case "E_DAILY_LIMIT_EXCEEDED":          console.error("Daily sending quota reached.");          break;        case "E_CONTENT_TOO_LARGE":          console.error("Email content exceeds size limit.");          break;        default:          console.error("Email sending failed:", error.message);      }    }  }}
```

TypeScript

```
class MyAgent extends Agent {  async onEmail(email: AgentEmail) {    try {      await this.replyToEmail(email, {        fromName: "Support Bot",        body: "Thanks for your email!",      });    } catch (error) {      switch (error.code) {        case "E_SENDER_NOT_VERIFIED":          console.error("Sender domain not verified. Verify in dashboard.");          break;        case "E_RATE_LIMIT_EXCEEDED":          console.error("Rate limit exceeded. Back off and retry.");          break;        case "E_DAILY_LIMIT_EXCEEDED":          console.error("Daily sending quota reached.");          break;        case "E_CONTENT_TOO_LARGE":          console.error("Email content exceeds size limit.");          break;        default:          console.error("Email sending failed:", error.message);      }    }  }}
```

### Common error codes

| Error Code                 | Description                        | Solution                             |
| -------------------------- | ---------------------------------- | ------------------------------------ |
| E\_SENDER\_NOT\_VERIFIED   | Sender domain/address not verified | Verify in Cloudflare dashboard       |
| E\_RATE\_LIMIT\_EXCEEDED   | Sending rate limit reached         | Implement exponential backoff        |
| E\_DAILY\_LIMIT\_EXCEEDED  | Daily quota exceeded               | Wait for quota reset or upgrade plan |
| E\_CONTENT\_TOO\_LARGE     | Email exceeds size limit           | Reduce attachments or content        |
| E\_RECIPIENT\_NOT\_ALLOWED | Recipient not in allowed list      | Check allowed destination addresses  |
| E\_RECIPIENT\_SUPPRESSED   | Recipient is on suppression list   | Remove from suppression list         |
| E\_VALIDATION\_ERROR       | Invalid email format               | Check email addresses                |
| E\_TOO\_MANY\_RECIPIENTS   | More than 50 recipients            | Split into multiple sends            |

## Secure reply routing

When your agent sends emails and expects replies, use secure reply routing to prevent attackers from forging headers to route emails to arbitrary agent instances.

### How it works

1. **Outbound:** When you call `replyToEmail()` or `sendEmail()` with a `secret`, the agent signs the routing headers (`X-Agent-Name`, `X-Agent-ID`) using HMAC-SHA256.
2. **Inbound:** `createSecureReplyEmailResolver` verifies the signature before routing.
3. **Enforcement:** If an email was routed via the secure resolver, `replyToEmail()` requires a secret (or explicit `null` to opt-out).

### Setup

1. Add a secret to your Worker:

  * [  wrangler.jsonc ](#tab-panel-5655)
  * [  wrangler.toml ](#tab-panel-5656)  
JSONC  
```  
{  "$schema": "./node_modules/wrangler/config-schema.json",  "vars": {    "EMAIL_SECRET": "change-me-in-production"  }}  
```  
TOML  
```  
[vars]EMAIL_SECRET = "change-me-in-production"  
```  
For production, use Wrangler secrets instead:  
Terminal window  
```  
npx wrangler secret put EMAIL_SECRET  
```
2. Use the combined resolver pattern:

  * [  JavaScript ](#tab-panel-5683)
  * [  TypeScript ](#tab-panel-5684)  
JavaScript  
```  
export default {  async email(message, env) {    const secureReplyResolver = createSecureReplyEmailResolver(      env.EMAIL_SECRET,    );    const addressResolver = createAddressBasedEmailResolver("EmailAgent");  
    await routeAgentEmail(message, env, {      resolver: async (email, env) => {        const replyRouting = await secureReplyResolver(email, env);        if (replyRouting) return replyRouting;        return addressResolver(email, env);      },    });  },};  
```  
TypeScript  
```  
export default { async email(message, env) {  const secureReplyResolver = createSecureReplyEmailResolver(   env.EMAIL_SECRET,  );  const addressResolver = createAddressBasedEmailResolver("EmailAgent");  
  await routeAgentEmail(message, env, {   resolver: async (email, env) => {    const replyRouting = await secureReplyResolver(email, env);    if (replyRouting) return replyRouting;    return addressResolver(email, env);   },  }); },} satisfies ExportedHandler<Env>;  
```
3. Sign outbound emails:

  * [  JavaScript ](#tab-panel-5681)
  * [  TypeScript ](#tab-panel-5682)  
JavaScript  
```  
class MyAgent extends Agent {  async onEmail(email) {    await this.replyToEmail(email, {      fromName: "My Agent",      body: "Thanks for your email!",      secret: this.env.EMAIL_SECRET, // Signs the routing headers    });  }}  
```  
TypeScript  
```  
class MyAgent extends Agent { async onEmail(email: AgentEmail) {  await this.replyToEmail(email, {   fromName: "My Agent",   body: "Thanks for your email!",   secret: this.env.EMAIL_SECRET, // Signs the routing headers  }); }}  
```

### Enforcement behavior

When an email is routed via `createSecureReplyEmailResolver`, the `replyToEmail()` method enforces signing:

| secret value        | Behavior                                                     |
| ------------------- | ------------------------------------------------------------ |
| "my-secret"         | Signs headers (secure)                                       |
| undefined (omitted) | **Throws error** \- must provide secret or explicit opt-out  |
| null                | Allowed but not recommended - explicitly opts out of signing |

## Complete example

Here is a complete Email Service agent that sends outbound mail and handles secure replies:

* [  JavaScript ](#tab-panel-5689)
* [  TypeScript ](#tab-panel-5690)

JavaScript

```
import { Agent, callable, routeAgentEmail } from "agents";import {  createAddressBasedEmailResolver,  createSecureReplyEmailResolver,} from "agents/email";import PostalMime from "postal-mime";
export class EmailAgent extends Agent {  @callable()  async sendWelcome(to) {    return this.sendEmail({      binding: this.env.EMAIL,      to,      from: "support@yourdomain.com",      subject: "Welcome!",      text: "Thanks for signing up.",      secret: this.env.EMAIL_SECRET,    });  }
  async onEmail(email) {    const raw = await email.getRaw();    const parsed = await PostalMime.parse(raw);
    console.log(`Email from ${email.from}: ${parsed.subject}`);
    const emails = this.state.emails || [];    emails.push({      from: email.from,      subject: parsed.subject,      receivedAt: new Date().toISOString(),    });    this.setState({ ...this.state, emails });
    await this.replyToEmail(email, {      fromName: "Support Bot",      body: `Thanks for your email! We received: "${parsed.subject}"`,      secret: this.env.EMAIL_SECRET,    });  }}
export default {  async email(message, env) {    const secureReplyResolver = createSecureReplyEmailResolver(      env.EMAIL_SECRET,      {        maxAge: 7 * 24 * 60 * 60, // 7 days        onInvalidSignature: (email, reason) => {          console.warn(`Invalid signature from ${email.from}: ${reason}`);        },      },    );    const addressResolver = createAddressBasedEmailResolver("EmailAgent");
    await routeAgentEmail(message, env, {      resolver: async (email, env) => {        const replyRouting = await secureReplyResolver(email, env);        if (replyRouting) return replyRouting;        return addressResolver(email, env);      },      onNoRoute: (email) => {        console.warn(`No route found for email from ${email.from}`);        email.setReject("Unknown recipient");      },    });  },};
```

TypeScript

```
import { Agent, callable, routeAgentEmail } from "agents";import {  createAddressBasedEmailResolver,  createSecureReplyEmailResolver,  type AgentEmail,} from "agents/email";import PostalMime from "postal-mime";
interface Env {  EmailAgent: DurableObjectNamespace<EmailAgent>;  EMAIL: SendEmail;  EMAIL_SECRET: string;}
export class EmailAgent extends Agent<Env> {  @callable()  async sendWelcome(to: string) {    return this.sendEmail({      binding: this.env.EMAIL,      to,      from: "support@yourdomain.com",      subject: "Welcome!",      text: "Thanks for signing up.",      secret: this.env.EMAIL_SECRET,    });  }
  async onEmail(email: AgentEmail) {    const raw = await email.getRaw();    const parsed = await PostalMime.parse(raw);
    console.log(`Email from ${email.from}: ${parsed.subject}`);
    const emails = this.state.emails || [];    emails.push({      from: email.from,      subject: parsed.subject,      receivedAt: new Date().toISOString(),    });    this.setState({ ...this.state, emails });
    await this.replyToEmail(email, {      fromName: "Support Bot",      body: `Thanks for your email! We received: "${parsed.subject}"`,      secret: this.env.EMAIL_SECRET,    });  }}
export default {  async email(message, env: Env) {    const secureReplyResolver = createSecureReplyEmailResolver(      env.EMAIL_SECRET,      {        maxAge: 7 * 24 * 60 * 60, // 7 days        onInvalidSignature: (email, reason) => {          console.warn(`Invalid signature from ${email.from}: ${reason}`);        },      },    );    const addressResolver = createAddressBasedEmailResolver("EmailAgent");
    await routeAgentEmail(message, env, {      resolver: async (email, env) => {        const replyRouting = await secureReplyResolver(email, env);        if (replyRouting) return replyRouting;        return addressResolver(email, env);      },      onNoRoute: (email) => {        console.warn(`No route found for email from ${email.from}`);        email.setReject("Unknown recipient");      },    });  },} satisfies ExportedHandler<Env>;
```

## API reference

### `EmailAddress`

TypeScript

```
interface EmailAddress {  email: string;  name?: string;}
```

### `sendEmail`

TypeScript

```
async sendEmail(options: {  binding: EmailSendBinding;  to: string | EmailAddress | (string | EmailAddress)[];  from: string | EmailAddress;  subject: string;  text?: string;  html?: string;  replyTo?: string | EmailAddress;  cc?: string | EmailAddress | (string | EmailAddress)[];  bcc?: string | EmailAddress | (string | EmailAddress)[];  inReplyTo?: string;  headers?: Record<string, string>;  secret?: string;}): Promise<EmailSendResult>;
```

Send an outbound email through the Email Service binding. Automatically injects `X-Agent-Name` and `X-Agent-ID` headers. When `secret` is provided, signs headers with HMAC-SHA256 for secure reply routing.

| Option    | Description                                                               |
| --------- | ------------------------------------------------------------------------- |
| binding   | The send\_email binding (for example, this.env.EMAIL). Required.          |
| to        | Recipient address, array of addresses, or EmailAddress object(s)          |
| from      | Sender address or EmailAddress object                                     |
| subject   | Email subject line                                                        |
| text      | Plain text body (at least one of text/html required)                      |
| html      | HTML body (at least one of text/html required)                            |
| replyTo   | Reply-to address or EmailAddress object                                   |
| cc        | CC recipient address, array of addresses, or EmailAddress object(s)       |
| bcc       | BCC recipient address, array of addresses, or EmailAddress object(s)      |
| inReplyTo | Message-ID for threading (sets the In-Reply-To header)                    |
| headers   | Additional custom headers (agent headers take precedence if they collide) |
| secret    | Secret for HMAC signing of agent routing headers                          |

### `routeAgentEmail`

TypeScript

```
function routeAgentEmail<Env>(  email: ForwardableEmailMessage,  env: Env,  options: {    resolver: EmailResolver;    onNoRoute?: (email: ForwardableEmailMessage) => void | Promise<void>;  },): Promise<void>;
```

Routes an incoming email to the appropriate Agent based on the resolver's decision.

| Option    | Description                                                                                                                                                                             |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| resolver  | Function that determines which agent to route the email to                                                                                                                              |
| onNoRoute | Optional callback invoked when no routing information is found. Use this to reject the email or perform custom handling. If not provided, a warning is logged and the email is dropped. |

### `createSecureReplyEmailResolver`

TypeScript

```
function createSecureReplyEmailResolver(  secret: string,  options?: {    maxAge?: number;    onInvalidSignature?: (      email: ForwardableEmailMessage,      reason: SignatureFailureReason,    ) => void;  },): EmailResolver;
type SignatureFailureReason =  | "missing_headers"  | "expired"  | "invalid"  | "malformed_timestamp";
```

Creates a resolver for routing email replies with signature verification.

| Option             | Description                                                              |
| ------------------ | ------------------------------------------------------------------------ |
| secret             | Secret key for HMAC verification (must match the key used to sign)       |
| maxAge             | Maximum age of signature in seconds (default: 30 days / 2592000 seconds) |
| onInvalidSignature | Optional callback for logging when signature verification fails          |

### `signAgentHeaders`

TypeScript

```
function signAgentHeaders(  secret: string,  agentName: string,  agentId: string,): Promise<Record<string, string>>;
```

Manually sign agent routing headers. Returns an object with `X-Agent-Name`, `X-Agent-ID`, `X-Agent-Sig`, and `X-Agent-Sig-Ts` headers.

Useful when sending emails through external services while maintaining secure reply routing. The signature includes a timestamp and will be valid for 30 days by default.

## Next steps

[ HTTP and SSE ](https://developers.cloudflare.com/agents/runtime/communication/http-sse/) Handle HTTP requests in your Agent. 

[ Webhooks ](https://developers.cloudflare.com/agents/communication-channels/webhooks/) Receive events from external services. 

[ Agents API ](https://developers.cloudflare.com/agents/runtime/agents-api/) Complete API reference for the Agents SDK.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/examples/email-agent/#page","headline":"Email agent · Cloudflare Agents docs","description":"Build an agent that sends, receives, routes, and replies to email using Cloudflare Email Service and the Agents SDK.","url":"https://developers.cloudflare.com/agents/examples/email-agent/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-09","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/examples/","name":"Examples"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/examples/email-agent/","name":"Email agent"}}]}
```

---

---
title: Slack agent
description: Build and deploy an AI-powered Slack bot on Cloudflare Workers using the Agents SDK.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Slack agent

## Deploy your first Slack Agent

This guide will show you how to build and deploy an AI-powered Slack bot on Cloudflare Workers that can:

* Respond to direct messages
* Reply when mentioned in channels
* Maintain conversation context in threads
* Use AI to generate intelligent responses

Your Slack Agent will be a multi-tenant application, meaning a single deployment can serve multiple Slack workspaces. Each workspace gets its own isolated agent instance with dedicated storage, powered by the [Agents SDK](https://developers.cloudflare.com/agents/).

You can view the full code for this example [here ↗](https://github.com/cloudflare/awesome-agents/tree/69963298b359ddd66331e8b3b378bb9ae666629f/agents/slack).

## Prerequisites

Before you begin, you will need:

* A [Cloudflare account ↗](https://dash.cloudflare.com/sign-up)
* [Node.js ↗](https://nodejs.org/) installed (v18 or later)
* A [Slack workspace ↗](https://slack.com/create) where you have permission to install apps
* An [OpenAI API key ↗](https://platform.openai.com/api-keys) (or another LLM provider)

## 1\. Create a Slack App

First, create a new Slack App that your agent will use to interact with Slack:

1. Go to [api.slack.com/apps ↗](https://api.slack.com/apps) and select **Create New App**.
2. Select **From scratch**.
3. Give your app a name (for example, "My AI Assistant") and select your workspace.
4. Select **Create App**.

### Configure OAuth & Permissions

In your Slack App settings, go to **OAuth & Permissions** and add the following **Bot Token Scopes**:

* `chat:write` — Send messages as the bot
* `chat:write.public` — Send messages to channels without joining
* `channels:history` — View messages in public channels
* `app_mentions:read` — Receive mentions
* `im:write` — Send direct messages
* `im:history` — View direct message history

### Enable Event Subscriptions

You will later configure the Event Subscriptions URL after deploying your agent. But for now, go to **Event Subscriptions** in your Slack App settings and prepare to enable it.

Subscribe to the following bot events:

* `app_mention` — When the bot is @mentioned
* `message.im` — Direct messages to the bot

Do not enable it yet. You will enable it after deployment.

### Get your Slack credentials

From your Slack App settings, collect these values:

1. **Basic Information** \> **App Credentials**:  
  * **Client ID**
  * **Client Secret**
  * **Signing Secret**

Keep these handy — you will need them in the next step.

## 2\. Create your Slack Agent project

1. Create a new project for your Slack Agent:

 npm  yarn  pnpm 

```
npm create cloudflare@latest -- my-slack-agent
```

```
yarn create cloudflare my-slack-agent
```

```
pnpm create cloudflare@latest my-slack-agent
```

1. Navigate into your project:

Terminal window

```
cd my-slack-agent
```

1. Install the required dependencies:

Terminal window

```
npm install agents openai
```

## 3\. Set up your environment variables

1. Create a `.env` file in your project root for local development secrets:

Terminal window

```
touch .env
```

1. Add your credentials to `.env`:

Terminal window

```
SLACK_CLIENT_ID="your-slack-client-id"SLACK_CLIENT_SECRET="your-slack-client-secret"SLACK_SIGNING_SECRET="your-slack-signing-secret"OPENAI_API_KEY="your-openai-api-key"OPENAI_BASE_URL="https://gateway.ai.cloudflare.com/v1/YOUR_ACCOUNT_ID/YOUR_GATEWAY/openai"
```

Note

The `OPENAI_BASE_URL` is optional but recommended. Using [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/) gives you caching, rate limiting, and analytics for your AI requests.

1. Update your `wrangler.jsonc` to configure your Agent:

* [  wrangler.jsonc ](#tab-panel-5691)
* [  wrangler.toml ](#tab-panel-5692)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  "name": "my-slack-agent",  "main": "src/index.ts",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": [    "nodejs_compat"  ],  "durable_objects": {    "bindings": [      {        "name": "MyAgent",        "class_name": "MyAgent",        "script_name": "my-slack-agent"      }    ]  },  "migrations": [    {      "tag": "v1",      "new_classes": [        "MyAgent"      ]    }  ]}
```

TOML

```
"$schema" = "./node_modules/wrangler/config-schema.json"name = "my-slack-agent"main = "src/index.ts"# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = [ "nodejs_compat" ]
[[durable_objects.bindings]]name = "MyAgent"class_name = "MyAgent"script_name = "my-slack-agent"
[[migrations]]tag = "v1"new_classes = [ "MyAgent" ]
```

## 4\. Create your Slack Agent

1. First, create the base `SlackAgent` class at `src/slack.ts`. This class handles OAuth, request verification, and event routing. You can view the [full implementation on GitHub ↗](https://github.com/cloudflare/awesome-agents/blob/69963298b359ddd66331e8b3b378bb9ae666629f/agents/slack/src/slack.ts).
2. Now create your agent implementation at `src/index.ts`:

TypeScript

```
import { env } from "cloudflare:workers";import { SlackAgent } from "./slack";import { OpenAI } from "openai";
const openai = new OpenAI({  apiKey: env.OPENAI_API_KEY,  baseURL: env.OPENAI_BASE_URL,});
type SlackMsg = {  user?: string;  text?: string;  ts: string;  thread_ts?: string;  subtype?: string;  bot_id?: string;};
function normalizeForLLM(msgs: SlackMsg[], selfUserId: string) {  return msgs.map((m) => {    const role = m.user && m.user !== selfUserId ? "user" : "assistant";    const text = (m.text ?? "").replace(/<@([A-Z0-9]+)>/g, "@$1");    return { role, content: text };  });}
export class MyAgent extends SlackAgent {  async generateAIReply(conversation: SlackMsg[]) {    const selfId = await this.ensureAppUserId();    const messages = normalizeForLLM(conversation, selfId);
    const system = `You are a helpful AI assistant in Slack.Be brief, specific, and actionable. If you're unsure, ask a single clarifying question.`;
    const input = [{ role: "system", content: system }, ...messages];
    const response = await openai.chat.completions.create({      model: "gpt-4o-mini",      messages: input,    });
    const msg = response.choices[0].message.content;    if (!msg) throw new Error("No message from AI");
    return msg;  }
  async onSlackEvent(event: { type: string } & Record<string, unknown>) {    // Ignore bot messages and subtypes (edits, joins, etc.)    if (event.bot_id || event.subtype) return;
    // Handle direct messages    if (event.type === "message") {      const e = event as unknown as SlackMsg & { channel: string };      const isDM = (e.channel || "").startsWith("D");      const mentioned = (e.text || "").includes(        `<@${await this.ensureAppUserId()}>`,      );
      if (!isDM && !mentioned) return;
      const conversation = await this.fetchConversation(e.channel);      const content = await this.generateAIReply(conversation);      await this.sendMessage(content, { channel: e.channel });      return;    }
    // Handle @mentions in channels    if (event.type === "app_mention") {      const e = event as unknown as SlackMsg & {        channel: string;        text?: string;      };      const thread = await this.fetchThread(e.channel, e.thread_ts || e.ts);      const content = await this.generateAIReply(thread);      await this.sendMessage(content, {        channel: e.channel,        thread_ts: e.thread_ts || e.ts,      });      return;    }  }}
export default MyAgent.listen({  clientId: env.SLACK_CLIENT_ID,  clientSecret: env.SLACK_CLIENT_SECRET,  slackSigningSecret: env.SLACK_SIGNING_SECRET,  scopes: [    "chat:write",    "chat:write.public",    "channels:history",    "app_mentions:read",    "im:write",    "im:history",  ],});
```

## 5\. Test locally

Start your development server:

Terminal window

```
npm run dev
```

Your agent is now running at `http://localhost:8787`.

### Configure Slack Event Subscriptions

Now that your agent is running locally, you need to expose it to Slack. Use [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) to create a secure tunnel:

Terminal window

```
npx cloudflared tunnel --url http://localhost:8787
```

This will output a public URL like `https://random-subdomain.trycloudflare.com`.

Go back to your Slack App settings:

1. Go to **Event Subscriptions**.
2. Toggle **Enable Events** to **On**.
3. Enter your Request URL: `https://random-subdomain.trycloudflare.com/slack`.
4. Slack will send a verification request — if your agent is running correctly, it should show **Verified**.
5. Under **Subscribe to bot events**, add:

  * `app_mention`
  * `message.im`
6. Select **Save Changes**.

Note

Cloudflare Tunnel URLs are temporary. When testing locally, you will need to update the Request URL each time you restart the tunnel.

### Install your app to Slack

Visit `http://localhost:8787/install` in your browser. This will redirect you to Slack's authorization page. Select **Allow** to install the app to your workspace.

After authorization, you should see "Successfully registered!" in your browser.

### Test your agent

Open Slack. Then:

1. Send a DM to your bot — it should respond with an AI-generated message.
2. Mention your bot in a channel (e.g., `@My AI Assistant hello`) — it should reply in a thread.

If everything works, you're ready to deploy to production!

## 6\. Deploy to production

1. Before deploying, add your secrets to Cloudflare:

Terminal window

```
npx wrangler secret put SLACK_CLIENT_IDnpx wrangler secret put SLACK_CLIENT_SECRETnpx wrangler secret put SLACK_SIGNING_SECRETnpx wrangler secret put OPENAI_API_KEYnpx wrangler secret put OPENAI_BASE_URL
```

Note

You can skip `OPENAI_BASE_URL` if you're not using AI Gateway.

1. Deploy your agent:

Terminal window

```
npx wrangler deploy
```

After deploying, you will get a production URL like:

```
https://my-slack-agent.your-account.workers.dev
```

### Update Slack Event Subscriptions

Go back to your Slack App settings:

1. Go to **Event Subscriptions**.
2. Update the Request URL to your production URL: `https://my-slack-agent.your-account.workers.dev/slack`.
3. Select **Save Changes**.

### Distribute your app

Now that your agent is deployed, you can share it with others:

* **Single workspace**: Install it via `https://my-slack-agent.your-account.workers.dev/install`.
* **Public distribution**: Submit your app to the [Slack App Directory ↗](https://api.slack.com/start/distributing).

Each workspace that installs your app will get its own isolated agent instance with dedicated storage.

## How it works

### Multi-tenancy with Durable Objects

Your Slack Agent uses [Durable Objects](https://developers.cloudflare.com/durable-objects/) to provide isolated, stateful instances for each Slack workspace:

* Each workspace's `team_id` is used as the Durable Object ID.
* Each agent instance stores its own Slack access token in KV storage.
* Conversations are fetched on-demand from Slack's API.
* All agent logic runs in an isolated, consistent environment.

### OAuth flow

The agent handles Slack's OAuth 2.0 flow:

1. User visits `/install` \> redirected to Slack authorization.
2. User selects **Allow** \> Slack redirects to `/accept` with an authorization code.
3. Agent exchanges code for access token.
4. Agent stores token in the workspace's Durable Object.

### Event handling

When Slack sends an event:

1. Request arrives at `/slack` endpoint.
2. Agent verifies the request signature using HMAC-SHA256.
3. Agent routes the event to the correct workspace's Durable Object.
4. `onSlackEvent` method processes the event and generates a response.

## Customizing your agent

### Change the AI model

Update the model in `src/index.ts`:

TypeScript

```
const response = await openai.chat.completions.create({  model: "gpt-4o", // or any other model  messages: input,});
```

### Add conversation memory

Store conversation history in Durable Object storage:

TypeScript

```
async storeMessage(channel: string, message: SlackMsg) {  const history = await this.ctx.storage.kv.get(`history:${channel}`) || [];  history.push(message);  await this.ctx.storage.kv.put(`history:${channel}`, history);}
```

### React to specific keywords

Add custom logic in `onSlackEvent`:

TypeScript

```
async onSlackEvent(event: { type: string } & Record<string, unknown>) {  if (event.type === "message") {    const e = event as unknown as SlackMsg & { channel: string };
    if (e.text?.includes("help")) {      await this.sendMessage("Here's how I can help...", {        channel: e.channel      });      return;    }  }
  // ... rest of your event handling}
```

### Use different LLM providers

Replace OpenAI with [Workers AI](https://developers.cloudflare.com/workers-ai/):

TypeScript

```
import { Ai } from "@cloudflare/ai";
export class MyAgent extends SlackAgent {  async generateAIReply(conversation: SlackMsg[]) {    const ai = new Ai(this.ctx.env.AI);    const response = await ai.run("@cf/meta/llama-3-8b-instruct", {      messages: normalizeForLLM(conversation, await this.ensureAppUserId()),    });    return response.response;  }}
```

## Next steps

* Add [Slack Interactive Components ↗](https://api.slack.com/interactivity) (buttons, modals)
* Connect your Agent to an [MCP server](https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/)
* Add rate limiting to prevent abuse
* Implement conversation state management
* Use [Workers Analytics Engine](https://developers.cloudflare.com/analytics/analytics-engine/) to track usage
* Add [schedules](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) for scheduled tasks

## Related resources

[ Agents documentation ](https://developers.cloudflare.com/agents/) Complete Agents framework documentation. 

[ Durable Objects ](https://developers.cloudflare.com/durable-objects/) Learn about the underlying stateful infrastructure. 

[ Slack API ](https://api.slack.com/) Official Slack API documentation. 

[ OpenAI API ](https://platform.openai.com/docs/) Official OpenAI API documentation.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/examples/slack-agent/#page","headline":"Slack agent · Cloudflare Agents docs","description":"Build and deploy an AI-powered Slack bot on Cloudflare Workers using the Agents SDK.","url":"https://developers.cloudflare.com/agents/examples/slack-agent/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/examples/","name":"Examples"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/examples/slack-agent/","name":"Slack agent"}}]}
```

---

---
title: Voice agent
description: Build a real-time voice agent with speech-to-text, LLM processing, and text-to-speech on Cloudflare Workers.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Voice agent

Build a voice agent that listens to users, thinks with an LLM, and speaks back — all in real-time over WebSocket. Beta

By the end of this guide you will have:

* A server-side voice agent with speech-to-text and text-to-speech
* An LLM-powered `onTurn` handler that streams responses
* Tools that the agent can call during conversation
* A React client with a push-to-talk style UI

## Prerequisites

* A Cloudflare account with [Workers AI](https://developers.cloudflare.com/workers-ai/) access
* Node.js 18+

## 1\. Create the project

Scaffold a new Workers project with Vite and React, then add the voice dependencies:

Terminal window

```
npm create cloudflare@latest voice-agent -- --template cloudflare/agents-startercd voice-agentnpm install @cloudflare/voice
```

The starter gives you a working Vite + React + Cloudflare Workers setup. You will replace the server and client code in the following steps.

## 2\. Configure wrangler

Update `wrangler.jsonc` to include a Workers AI binding and a Durable Object for your voice agent:

* [  wrangler.jsonc ](#tab-panel-5693)
* [  wrangler.toml ](#tab-panel-5694)

JSONC

```
{  "name": "voice-agent",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": ["nodejs_compat"],  "main": "src/server.ts",  "ai": {    "binding": "AI"  },  "durable_objects": {    "bindings": [      {        "name": "MyVoiceAgent",        "class_name": "MyVoiceAgent"      }    ]  },  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": ["MyVoiceAgent"]    }  ]}
```

TOML

```
name = "voice-agent"# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = [ "nodejs_compat" ]main = "src/server.ts"
[ai]binding = "AI"
[[durable_objects.bindings]]name = "MyVoiceAgent"class_name = "MyVoiceAgent"
[[migrations]]tag = "v1"new_sqlite_classes = [ "MyVoiceAgent" ]
```

## 3\. Build the server

Replace `src/server.ts` with the following. The `withVoice` mixin adds the full voice pipeline — STT, sentence chunking, TTS, and conversation persistence — to a standard `Agent` class.

* [  JavaScript ](#tab-panel-5699)
* [  TypeScript ](#tab-panel-5700)

JavaScript

```
import { Agent, routeAgentRequest } from "agents";import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from "@cloudflare/voice";import { streamText, tool, stepCountIs } from "ai";import { createWorkersAI } from "workers-ai-provider";import { z } from "zod";
const VoiceAgent = withVoice(Agent);
export class MyVoiceAgent extends VoiceAgent {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);
  async onTurn(transcript, context) {    const workersAi = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersAi("@cf/moonshotai/kimi-k2.6"),      system:        "You are a helpful voice assistant. Keep responses concise — you are being spoken aloud.",      messages: [        ...context.messages.map((m) => ({          role: m.role,          content: m.content,        })),        { role: "user", content: transcript },      ],      tools: {        get_current_time: tool({          description: "Get the current date and time.",          inputSchema: z.object({}),          execute: async () => ({            time: new Date().toLocaleTimeString("en-US", {              hour: "2-digit",              minute: "2-digit",            }),          }),        }),      },      stopWhen: stepCountIs(3),      abortSignal: context.signal,    });
    return result.textStream;  }
  async onCallStart(connection) {    await this.speak(connection, "Hi there! How can I help you today?");  }}
export default {  async fetch(request, env) {    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
import { Agent, routeAgentRequest, type Connection } from "agents";import {  withVoice,  WorkersAIFluxSTT,  WorkersAITTS,  type VoiceTurnContext,} from "@cloudflare/voice";import { streamText, tool, stepCountIs } from "ai";import { createWorkersAI } from "workers-ai-provider";import { z } from "zod";
const VoiceAgent = withVoice(Agent);
export class MyVoiceAgent extends VoiceAgent<Env> {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);
  async onTurn(transcript: string, context: VoiceTurnContext) {    const workersAi = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersAi("@cf/moonshotai/kimi-k2.6"),      system:        "You are a helpful voice assistant. Keep responses concise — you are being spoken aloud.",      messages: [        ...context.messages.map((m) => ({          role: m.role as "user" | "assistant",          content: m.content,        })),        { role: "user" as const, content: transcript },      ],      tools: {        get_current_time: tool({          description: "Get the current date and time.",          inputSchema: z.object({}),          execute: async () => ({            time: new Date().toLocaleTimeString("en-US", {              hour: "2-digit",              minute: "2-digit",            }),          }),        }),      },      stopWhen: stepCountIs(3),      abortSignal: context.signal,    });
    return result.textStream;  }
  async onCallStart(connection: Connection) {    await this.speak(connection, "Hi there! How can I help you today?");  }}
export default {  async fetch(request: Request, env: Env) {    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

Key points:

* `WorkersAIFluxSTT` handles continuous speech-to-text — the model detects when the user finishes speaking.
* `WorkersAITTS` converts the LLM response to audio, sentence by sentence.
* `onTurn` receives the transcript and returns a stream. The mixin handles chunking the stream into sentences and synthesizing each one.
* `onCallStart` sends a greeting when the user connects.
* `context.messages` contains the full conversation history from SQLite.
* `context.signal` is aborted if the user interrupts or disconnects.

## 4\. Build the client

Replace `src/client.tsx` with a React component using the `useVoiceAgent` hook. The hook manages the WebSocket connection, mic capture, audio playback, and interrupt detection.

```
import { useVoiceAgent } from "@cloudflare/voice/react";
function App() {  const {    status,    transcript,    interimTranscript,    metrics,    audioLevel,    isMuted,    startCall,    endCall,    toggleMute,  } = useVoiceAgent({ agent: "MyVoiceAgent" });
  return (    <div>      <h1>Voice Agent</h1>      <p>Status: {status}</p>
      <div>        <button onClick={status === "idle" ? startCall : endCall}>          {status === "idle" ? "Start Call" : "End Call"}        </button>        {status !== "idle" && (          <button onClick={toggleMute}>{isMuted ? "Unmute" : "Mute"}</button>        )}      </div>
      {interimTranscript && (        <p>          <em>{interimTranscript}</em>        </p>      )}
      {transcript.map((msg, i) => (        <p key={i}>          <strong>{msg.role}:</strong> {msg.text}        </p>      ))}
      {metrics && (        <p>          LLM: {metrics.llm_ms}ms | TTS: {metrics.tts_ms}ms | First audio:{" "}          {metrics.first_audio_ms}ms        </p>      )}    </div>  );}
```

The `status` field cycles through `"idle"` → `"listening"` → `"thinking"` → `"speaking"` → `"listening"`, giving you everything you need to build a responsive UI.

## 5\. Run it

Terminal window

```
npm run dev
```

Open the app in your browser, select **Start Call**, and speak. You will see the transcript appear in real time, and the agent's response will play through your speakers.

## Adding pipeline hooks

You can intercept and transform data at each stage of the pipeline. For example, filter out short transcripts (noise) and adjust pronunciation before TTS:

* [  JavaScript ](#tab-panel-5695)
* [  TypeScript ](#tab-panel-5696)

JavaScript

```
export class MyVoiceAgent extends VoiceAgent {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);
  afterTranscribe(transcript, connection) {    if (transcript.length < 3) return null;    return transcript;  }
  beforeSynthesize(text, connection) {    return text.replace(/\bAI\b/g, "A.I.");  }
  async onTurn(transcript, context) {    return "You said: " + transcript;  }}
```

TypeScript

```
export class MyVoiceAgent extends VoiceAgent<Env> {  transcriber = new WorkersAIFluxSTT(this.env.AI);  tts = new WorkersAITTS(this.env.AI);
  afterTranscribe(transcript: string, connection: Connection) {    if (transcript.length < 3) return null;    return transcript;  }
  beforeSynthesize(text: string, connection: Connection) {    return text.replace(/\bAI\b/g, "A.I.");  }
  async onTurn(transcript: string, context: VoiceTurnContext) {    return "You said: " + transcript;  }}
```

Returning `null` from `afterTranscribe` drops the utterance entirely — useful for filtering noise or very short transcripts.

## Using third-party providers

Swap in third-party STT or TTS providers without changing your agent logic:

* [  JavaScript ](#tab-panel-5697)
* [  TypeScript ](#tab-panel-5698)

JavaScript

```
import { ElevenLabsTTS } from "@cloudflare/voice-elevenlabs";import { DeepgramSTT } from "@cloudflare/voice-deepgram";
export class MyVoiceAgent extends VoiceAgent {  transcriber = new DeepgramSTT({    apiKey: this.env.DEEPGRAM_API_KEY,  });
  tts = new ElevenLabsTTS({    apiKey: this.env.ELEVENLABS_API_KEY,    voiceId: "21m00Tcm4TlvDq8ikWAM",  });
  async onTurn(transcript, context) {    return "You said: " + transcript;  }}
```

TypeScript

```
import { ElevenLabsTTS } from "@cloudflare/voice-elevenlabs";import { DeepgramSTT } from "@cloudflare/voice-deepgram";
export class MyVoiceAgent extends VoiceAgent<Env> {  transcriber = new DeepgramSTT({    apiKey: this.env.DEEPGRAM_API_KEY,  });
  tts = new ElevenLabsTTS({    apiKey: this.env.ELEVENLABS_API_KEY,    voiceId: "21m00Tcm4TlvDq8ikWAM",  });
  async onTurn(transcript: string, context: VoiceTurnContext) {    return "You said: " + transcript;  }}
```

## Next steps

[ Voice agents API reference ](https://developers.cloudflare.com/agents/communication-channels/voice/) Full reference for withVoice, withVoiceInput, React hooks, VoiceClient, and all providers. 

[ Chat agents ](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/) Build text-based AI chat with AIChatAgent and useAgentChat. 

[ Using AI models ](https://developers.cloudflare.com/agents/runtime/operations/using-ai-models/) Use Workers AI, OpenAI, Anthropic, Gemini, or any provider with your agents.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/examples/voice-agent/#page","headline":"Voice agent · Cloudflare Agents docs","description":"Build a real-time voice agent with speech-to-text, LLM processing, and text-to-speech on Cloudflare Workers.","url":"https://developers.cloudflare.com/agents/examples/voice-agent/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/examples/","name":"Examples"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/examples/voice-agent/","name":"Voice agent"}}]}
```

---

---
title: Add to existing project
description: Add the Agents SDK to an existing Cloudflare Workers project with state management and real-time connections.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Add to existing project

This guide shows how to add agents to an existing Cloudflare Workers project. If you are starting fresh, refer to [Building a chat agent](https://developers.cloudflare.com/agents/examples/chat-agent/) instead.

## Prerequisites

* An existing Cloudflare Workers project with a Wrangler configuration file
* Node.js 18 or newer

## 1\. Install the package

 npm  yarn  pnpm  bun 

```
npm i agents
```

```
yarn add agents
```

```
pnpm add agents
```

```
bun add agents
```

For React applications, no additional packages are needed — React bindings are included.

For Hono applications:

 npm  yarn  pnpm  bun 

```
npm i agents hono-agents
```

```
yarn add agents hono-agents
```

```
pnpm add agents hono-agents
```

```
bun add agents hono-agents
```

## 2\. Create an Agent

Create a new file for your agent (for example, `src/agents/counter.ts`):

* [  JavaScript ](#tab-panel-5709)
* [  TypeScript ](#tab-panel-5710)

JavaScript

```
import { Agent, callable } from "agents";
export class CounterAgent extends Agent {  initialState = { count: 0 };
  @callable()  increment() {    this.setState({ count: this.state.count + 1 });    return this.state.count;  }
  @callable()  decrement() {    this.setState({ count: this.state.count - 1 });    return this.state.count;  }}
```

TypeScript

```
import { Agent, callable } from "agents";
export type CounterState = {  count: number;};
export class CounterAgent extends Agent<Env, CounterState> {  initialState: CounterState = { count: 0 };
  @callable()  increment() {    this.setState({ count: this.state.count + 1 });    return this.state.count;  }
  @callable()  decrement() {    this.setState({ count: this.state.count - 1 });    return this.state.count;  }}
```

## 3\. Update Wrangler configuration

Add the Durable Object binding and migration:

* [  wrangler.jsonc ](#tab-panel-5701)
* [  wrangler.toml ](#tab-panel-5702)

JSONC

```
{  "name": "my-existing-project",  "main": "src/index.ts",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": ["nodejs_compat"],
  "durable_objects": {    "bindings": [      {        "name": "CounterAgent",        "class_name": "CounterAgent",      },    ],  },
  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": ["CounterAgent"],    },  ],}
```

TOML

```
name = "my-existing-project"main = "src/index.ts"# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = [ "nodejs_compat" ]
[[durable_objects.bindings]]name = "CounterAgent"class_name = "CounterAgent"
[[migrations]]tag = "v1"new_sqlite_classes = [ "CounterAgent" ]
```

**Key points:**

* `name` in bindings becomes the property on `env` (for example, `env.CounterAgent`)
* `class_name` must exactly match your exported class name
* `new_sqlite_classes` enables SQLite storage for state persistence
* `nodejs_compat` flag is required for the agents package

## 4\. Configure TypeScript and Vite

If you use `@callable()` decorators (as in the example above), you need two build configurations.

**tsconfig.json** — extend `agents/tsconfig` (or set `"target": "ES2021"` manually):

```
{  "extends": "agents/tsconfig"}
```

If you have an existing `tsconfig.json` with custom settings, you can extend and override:

```
{  "extends": "agents/tsconfig",  "compilerOptions": {    "paths": { "~/*": ["./src/*"] }  }}
```

**vite.config.ts** — add the `agents()` plugin (handles TC39 decorator transforms for Vite 8):

* [  JavaScript ](#tab-panel-5705)
* [  TypeScript ](#tab-panel-5706)

JavaScript

```
import agents from "agents/vite";
export default defineConfig({  plugins: [    agents(),    // ... your existing plugins  ],});
```

TypeScript

```
import agents from "agents/vite";
export default defineConfig({  plugins: [    agents(),    // ... your existing plugins  ],});
```

If your project does not use Vite, the `tsconfig.json` change alone is sufficient — your bundler must support TC39 decorators (stage 3, version `2023-11`).

For more details, refer to the [TypeScript configuration](https://developers.cloudflare.com/agents/runtime/operations/configuration/#typescript-configuration) and [Vite configuration](https://developers.cloudflare.com/agents/runtime/operations/configuration/#vite-configuration) reference.

## 5\. Export the Agent class

Your agent class must be exported from your main entry point. Update your `src/index.ts`:

* [  JavaScript ](#tab-panel-5707)
* [  TypeScript ](#tab-panel-5708)

JavaScript

```
// Export the agent class (required for Durable Objects)export { CounterAgent } from "./agents/counter";
// Your existing exports...export default {  // ...};
```

TypeScript

```
// Export the agent class (required for Durable Objects)export { CounterAgent } from "./agents/counter";
// Your existing exports...export default {  // ...} satisfies ExportedHandler<Env>;
```

## 6\. Wire up routing

Choose the approach that matches your project structure:

### Plain Workers (fetch handler)

* [  JavaScript ](#tab-panel-5713)
* [  TypeScript ](#tab-panel-5714)

JavaScript

```
import { routeAgentRequest } from "agents";export { CounterAgent } from "./agents/counter";
export default {  async fetch(request, env, ctx) {    // Try agent routing first    const agentResponse = await routeAgentRequest(request, env);    if (agentResponse) return agentResponse;
    // Your existing routing logic    const url = new URL(request.url);    if (url.pathname === "/api/hello") {      return Response.json({ message: "Hello!" });    }
    return new Response("Not found", { status: 404 });  },};
```

TypeScript

```
import { routeAgentRequest } from "agents";export { CounterAgent } from "./agents/counter";
export default {  async fetch(request: Request, env: Env, ctx: ExecutionContext) {    // Try agent routing first    const agentResponse = await routeAgentRequest(request, env);    if (agentResponse) return agentResponse;
    // Your existing routing logic    const url = new URL(request.url);    if (url.pathname === "/api/hello") {      return Response.json({ message: "Hello!" });    }
    return new Response("Not found", { status: 404 });  },} satisfies ExportedHandler<Env>;
```

### Hono

* [  JavaScript ](#tab-panel-5711)
* [  TypeScript ](#tab-panel-5712)

JavaScript

```
import { Hono } from "hono";import { agentsMiddleware } from "hono-agents";export { CounterAgent } from "./agents/counter";
const app = new Hono();
// Add agents middleware - handles WebSocket upgrades and agent HTTP requestsapp.use("*", agentsMiddleware());
// Your existing routes continue to workapp.get("/api/hello", (c) => c.json({ message: "Hello!" }));
export default app;
```

TypeScript

```
import { Hono } from "hono";import { agentsMiddleware } from "hono-agents";export { CounterAgent } from "./agents/counter";
const app = new Hono<{ Bindings: Env }>();
// Add agents middleware - handles WebSocket upgrades and agent HTTP requestsapp.use("*", agentsMiddleware());
// Your existing routes continue to workapp.get("/api/hello", (c) => c.json({ message: "Hello!" }));
export default app;
```

### With static assets

If you are serving static assets alongside agents, static assets are served first by default. Your Worker code only runs for paths that do not match a static asset:

* [  JavaScript ](#tab-panel-5715)
* [  TypeScript ](#tab-panel-5716)

JavaScript

```
import { routeAgentRequest } from "agents";export { CounterAgent } from "./agents/counter";
export default {  async fetch(request, env, ctx) {    // Static assets are served automatically before this runs    // This only handles non-asset requests
    // Route to agents    const agentResponse = await routeAgentRequest(request, env);    if (agentResponse) return agentResponse;
    return new Response("Not found", { status: 404 });  },};
```

TypeScript

```
import { routeAgentRequest } from "agents";export { CounterAgent } from "./agents/counter";
export default {  async fetch(request: Request, env: Env, ctx: ExecutionContext) {    // Static assets are served automatically before this runs    // This only handles non-asset requests
    // Route to agents    const agentResponse = await routeAgentRequest(request, env);    if (agentResponse) return agentResponse;
    return new Response("Not found", { status: 404 });  },} satisfies ExportedHandler<Env>;
```

Configure assets in the Wrangler configuration file:

* [  wrangler.jsonc ](#tab-panel-5703)
* [  wrangler.toml ](#tab-panel-5704)

JSONC

```
{  "assets": {    "directory": "./public",  },}
```

TOML

```
[assets]directory = "./public"
```

## 7\. Generate TypeScript types

Do not hand-write your `Env` interface. Run [wrangler types](https://developers.cloudflare.com/workers/wrangler/commands/general/#types) to generate a type definition file that matches your Wrangler configuration. This catches mismatches between your config and code at compile time instead of at deploy time.

Re-run `wrangler types` whenever you add or rename a binding.

Terminal window

```
npx wrangler types
```

This creates a type definition file with all your bindings typed, including your agent Durable Object namespaces. The `Agent` class defaults to using the generated `Env` type, so you do not need to pass it as a type parameter — `extends Agent` is sufficient unless you need to pass a second type parameter for state (for example, `Agent<Env, CounterState>`).

Refer to [Configuration](https://developers.cloudflare.com/agents/runtime/operations/configuration/#generating-types) for more details on type generation.

## 8\. Connect from the frontend

### React

* [  JavaScript ](#tab-panel-5717)
* [  TypeScript ](#tab-panel-5718)

JavaScript

```
import { useState } from "react";import { useAgent } from "agents/react";
function CounterWidget() {  const [count, setCount] = useState(0);
  const agent = useAgent({    agent: "CounterAgent",    onStateUpdate: (state) => setCount(state.count),  });
  return (    <>      {count}      <button onClick={() => agent.stub.increment()}>+</button>      <button onClick={() => agent.stub.decrement()}>-</button>    </>  );}
```

TypeScript

```
import { useState } from "react";import { useAgent } from "agents/react";import type { CounterAgent, CounterState } from "./agents/counter";
function CounterWidget() {  const [count, setCount] = useState(0);
  const agent = useAgent<CounterAgent, CounterState>({    agent: "CounterAgent",    onStateUpdate: (state) => setCount(state.count),  });
  return (    <>      {count}      <button onClick={() => agent.stub.increment()}>+</button>      <button onClick={() => agent.stub.decrement()}>-</button>    </>  );}
```

Key points:

* `useAgent` connects to your agent via WebSocket
* `onStateUpdate` fires whenever the agent's state changes
* `agent.stub.methodName()` calls methods marked with `@callable()` on your agent

### Vanilla JavaScript

* [  JavaScript ](#tab-panel-5719)
* [  TypeScript ](#tab-panel-5720)

JavaScript

```
import { AgentClient } from "agents/client";
const agent = new AgentClient({  agent: "CounterAgent",  name: "user-123", // Optional: unique instance name  onStateUpdate: (state) => {    document.getElementById("count").textContent = state.count;  },});
// Call methodsdocument.getElementById("increment").onclick = () => agent.call("increment");
```

TypeScript

```
import { AgentClient } from "agents/client";
const agent = new AgentClient({  agent: "CounterAgent",  name: "user-123", // Optional: unique instance name  onStateUpdate: (state) => {    document.getElementById("count").textContent = state.count;  },});
// Call methodsdocument.getElementById("increment").onclick = () => agent.call("increment");
```

## How it works

When you clicked the button:

1. **Client** called `agent.stub.increment()` over WebSocket
2. **Agent** ran `increment()`, updated state with `setState()`
3. **State** persisted to SQLite automatically
4. **Broadcast** sent to all connected clients
5. **React** updated via `onStateUpdate`

flowchart LR
    A["Browser<br/>(React)"] <-->|WebSocket| B["Agent<br/>(Counter)"]
    B --> C["SQLite<br/>(State)"]

### Key concepts

| Concept              | What it means                                                                                     |
| -------------------- | ------------------------------------------------------------------------------------------------- |
| **Agent instance**   | Each unique name gets its own agent. CounterAgent:user-123 is separate from CounterAgent:user-456 |
| **Persistent state** | State survives restarts, deploys, and hibernation. It is stored in SQLite                         |
| **Real-time sync**   | All clients connected to the same agent receive state updates instantly                           |
| **Hibernation**      | When no clients are connected, the agent hibernates (no cost). It wakes on the next request       |

## Deploy to Cloudflare

Terminal window

```
npm run deploy
```

Your agent is now live on Cloudflare's global network, running close to your users.

## Common integration patterns

### Agents behind authentication

Check auth before routing to agents:

* [  JavaScript ](#tab-panel-5729)
* [  TypeScript ](#tab-panel-5730)

JavaScript

```
export default {  async fetch(request, env) {    // Check auth for agent routes    if (request.url.includes("/agents/")) {      const authResult = await checkAuth(request, env);      if (!authResult.valid) {        return new Response("Unauthorized", { status: 401 });      }    }
    const agentResponse = await routeAgentRequest(request, env);    if (agentResponse) return agentResponse;
    // ... rest of routing  },};
```

TypeScript

```
export default {  async fetch(request: Request, env: Env) {    // Check auth for agent routes    if (request.url.includes("/agents/")) {      const authResult = await checkAuth(request, env);      if (!authResult.valid) {        return new Response("Unauthorized", { status: 401 });      }    }
    const agentResponse = await routeAgentRequest(request, env);    if (agentResponse) return agentResponse;
    // ... rest of routing  },} satisfies ExportedHandler<Env>;
```

### Custom agent path prefix

By default, agents are routed at `/agents/{agent-name}/{instance-name}`. You can customize this:

* [  JavaScript ](#tab-panel-5721)
* [  TypeScript ](#tab-panel-5722)

JavaScript

```
import { routeAgentRequest } from "agents";
const agentResponse = await routeAgentRequest(request, env, {  prefix: "/api/agents", // Now routes at /api/agents/{agent-name}/{instance-name}});
```

TypeScript

```
import { routeAgentRequest } from "agents";
const agentResponse = await routeAgentRequest(request, env, {  prefix: "/api/agents", // Now routes at /api/agents/{agent-name}/{instance-name}});
```

Refer to [Routing](https://developers.cloudflare.com/agents/runtime/communication/routing/) for more options including CORS, custom instance naming, and location hints.

### Accessing agents from server code

You can interact with agents directly from your Worker code:

* [  JavaScript ](#tab-panel-5733)
* [  TypeScript ](#tab-panel-5734)

JavaScript

```
import { getAgentByName } from "agents";
export default {  async fetch(request, env) {    if (request.url.endsWith("/api/increment")) {      // Get a specific agent instance      const counter = await getAgentByName(env.CounterAgent, "shared-counter");      const newCount = await counter.increment();      return Response.json({ count: newCount });    }    // ...  },};
```

TypeScript

```
import { getAgentByName } from "agents";
export default {  async fetch(request: Request, env: Env) {    if (request.url.endsWith("/api/increment")) {      // Get a specific agent instance      const counter = await getAgentByName(env.CounterAgent, "shared-counter");      const newCount = await counter.increment();      return Response.json({ count: newCount });    }    // ...  },} satisfies ExportedHandler<Env>;
```

### Adding multiple agents

Add more agents by extending the configuration:

* [  JavaScript ](#tab-panel-5727)
* [  TypeScript ](#tab-panel-5728)

JavaScript

```
// src/agents/chat.tsexport class Chat extends Agent {  // ...}
// src/agents/scheduler.tsexport class Scheduler extends Agent {  // ...}
```

TypeScript

```
// src/agents/chat.tsexport class Chat extends Agent {  // ...}
// src/agents/scheduler.tsexport class Scheduler extends Agent {  // ...}
```

Update the Wrangler configuration file:

* [  wrangler.jsonc ](#tab-panel-5737)
* [  wrangler.toml ](#tab-panel-5738)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  "durable_objects": {    "bindings": [      {        "name": "CounterAgent",        "class_name": "CounterAgent"      },      {        "name": "Chat",        "class_name": "Chat"      },      {        "name": "Scheduler",        "class_name": "Scheduler"      }    ]  },  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": [        "CounterAgent",        "Chat",        "Scheduler"      ]    }  ]}
```

TOML

```
[[durable_objects.bindings]]name = "CounterAgent"class_name = "CounterAgent"
[[durable_objects.bindings]]name = "Chat"class_name = "Chat"
[[durable_objects.bindings]]name = "Scheduler"class_name = "Scheduler"
[[migrations]]tag = "v1"new_sqlite_classes = ["CounterAgent", "Chat", "Scheduler"]
```

Export all agents from your entry point:

* [  JavaScript ](#tab-panel-5725)
* [  TypeScript ](#tab-panel-5726)

JavaScript

```
export { CounterAgent } from "./agents/counter";export { Chat } from "./agents/chat";export { Scheduler } from "./agents/scheduler";
```

TypeScript

```
export { CounterAgent } from "./agents/counter";export { Chat } from "./agents/chat";export { Scheduler } from "./agents/scheduler";
```

## Troubleshooting

### Agent not found, or 404 errors

1. **Check the export** \- Agent class must be exported from your main entry point.
2. **Check the binding** \- `class_name` in the Wrangler configuration file must exactly match the exported class name.
3. **Check the route** \- Default route is `/agents/{'{agent-name}'}/{'{instance-name}'}`. Agent name in client matches the class name (case-insensitive).

### No such Durable Object class error

Add the migration to the Wrangler configuration file:

* [  wrangler.jsonc ](#tab-panel-5723)
* [  wrangler.toml ](#tab-panel-5724)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": [        "YourAgentClass"      ]    }  ]}
```

TOML

```
[[migrations]]tag = "v1"new_sqlite_classes = ["YourAgentClass"]
```

### WebSocket connection fails

Ensure your routing passes the response unchanged:

* [  JavaScript ](#tab-panel-5731)
* [  TypeScript ](#tab-panel-5732)

JavaScript

```
// Correct - return the response directlyconst agentResponse = await routeAgentRequest(request, env);if (agentResponse) return agentResponse;
// Wrong - this breaks WebSocket connectionsif (agentResponse) return new Response(agentResponse.body);
```

TypeScript

```
// Correct - return the response directlyconst agentResponse = await routeAgentRequest(request, env);if (agentResponse) return agentResponse;
// Wrong - this breaks WebSocket connectionsif (agentResponse) return new Response(agentResponse.body);
```

### State not persisting

Check that:

1. You are calling `this.setState()`, not mutating `this.state` directly.
2. The agent class is in `new_sqlite_classes` in migrations.
3. You are connecting to the same agent instance name.
4. The `onStateUpdate` callback is wired up in your client.
5. WebSocket connection is established (check browser dev tools).

### "Method X is not callable" errors

Make sure your methods are decorated with `@callable()`:

* [  JavaScript ](#tab-panel-5735)
* [  TypeScript ](#tab-panel-5736)

JavaScript

```
import { Agent, callable } from "agents";
export class MyAgent extends Agent {  @callable()  increment() {    // ...  }}
```

TypeScript

```
import { Agent, callable } from "agents";
export class MyAgent extends Agent {  @callable()  increment() {    // ...  }}
```

### Type errors with `agent.stub`

Add the agent and state type parameters:

* [  JavaScript ](#tab-panel-5739)
* [  TypeScript ](#tab-panel-5740)

JavaScript

```
import { useAgent } from "agents/react";
// Pass the agent and state types to useAgentconst agent = useAgent({  agent: "CounterAgent",  onStateUpdate: (state) => setCount(state.count),});
// Now agent.stub is fully typedagent.stub.increment();
```

TypeScript

```
import { useAgent } from "agents/react";import type { CounterAgent, CounterState } from "./server";
// Pass the agent and state types to useAgentconst agent = useAgent<CounterAgent, CounterState>({  agent: "CounterAgent",  onStateUpdate: (state) => setCount(state.count),});
// Now agent.stub is fully typedagent.stub.increment();
```

### `SyntaxError: Invalid or unexpected token` with `@callable()`

If your dev server fails with `SyntaxError: Invalid or unexpected token`, set `"target": "ES2021"` in your `tsconfig.json`. This ensures that Vite's esbuild transpiler downlevels TC39 decorators instead of passing them through as native syntax.

```
{  "compilerOptions": {    "target": "ES2021"  }}
```

Warning

Do not set `"experimentalDecorators": true` in your `tsconfig.json`. The Agents SDK uses [TC39 standard decorators ↗](https://github.com/tc39/proposal-decorators), not TypeScript legacy decorators. Enabling `experimentalDecorators` applies an incompatible transform that silently breaks `@callable()` at runtime.

## Next steps

Now that you have a working agent, explore these topics:

### Common next steps

| Learn how to             | Refer to                                                                                        |
| ------------------------ | ----------------------------------------------------------------------------------------------- |
| Add AI/LLM capabilities  | [Using AI models](https://developers.cloudflare.com/agents/runtime/operations/using-ai-models/) |
| Expose tools via MCP     | [MCP servers](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/)  |
| Run background tasks     | [Schedule tasks](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/)    |
| Handle emails            | [Email routing](https://developers.cloudflare.com/agents/communication-channels/email/)         |
| Use Cloudflare Workflows | [Run Workflows](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/)      |

### Explore more

[ State management ](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) Deep dive into setState(), initialState, and onStateChanged(). 

[ Client SDK ](https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/) Full useAgent and AgentClient API reference. 

[ Callable methods ](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/) Expose methods to clients with @callable(). 

[ Schedule tasks ](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) Run tasks on a delay, schedule, or cron. 

[ Agent class internals ](https://developers.cloudflare.com/agents/runtime/lifecycle/agent-class/) Full lifecycle and methods reference. 

[ Agents API ](https://developers.cloudflare.com/agents/runtime/agents-api/) Complete API reference for the Agents SDK.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/getting-started/add-to-existing-project/#page","headline":"Add to existing project · Cloudflare Agents docs","description":"Add the Agents SDK to an existing Cloudflare Workers project with state management and real-time connections.","url":"https://developers.cloudflare.com/agents/getting-started/add-to-existing-project/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-09","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/getting-started/","name":"Getting started"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/getting-started/add-to-existing-project/","name":"Add to existing project"}}]}
```

---

---
title: Quick start
description: Build your first agent in 10 minutes — a counter with persistent state that syncs to a React frontend in real-time.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Quick start

Build AI agents that persist, think, and act. Agents run on Cloudflare's global network, maintain state across requests, and connect to clients in real-time via WebSockets.

**What you will build:** A counter agent with persistent state that syncs to a React frontend in real-time.

**Time:** \~10 minutes

## Create a new project

 npm  yarn  pnpm 

```
npm create cloudflare@latest -- --template cloudflare/agents-starter
```

```
yarn create cloudflare --template cloudflare/agents-starter
```

```
pnpm create cloudflare@latest --template cloudflare/agents-starter
```

Then install dependencies and start the dev server:

Terminal window

```
cd agents-starternpm installnpm run dev
```

This creates a project with:

* `src/server.ts` — Your agent code
* `src/client.tsx` — React frontend
* `wrangler.jsonc` — Cloudflare configuration
* `tsconfig.json` — Extends `agents/tsconfig` for correct decorator and module settings
* `vite.config.ts` — Includes the `agents/vite` plugin for decorator support

The starter template includes two important SDK integrations. If you are setting up a project manually, add both:

**tsconfig.json** — extends `agents/tsconfig`, which sets `target: "ES2021"` and other recommended options:

```
{  "extends": "agents/tsconfig"}
```

**vite.config.ts** — includes the `agents()` plugin, which handles TC39 decorator transforms (required for `@callable()` in Vite 8):

TypeScript

```
import { cloudflare } from "@cloudflare/vite-plugin";import react from "@vitejs/plugin-react";import agents from "agents/vite";import { defineConfig } from "vite";
export default defineConfig({  plugins: [agents(), react(), cloudflare()],});
```

Open [http://localhost:5173 ↗](http://localhost:5173) to see your agent in action.

## Your first agent

Build a simple counter agent from scratch. Replace `src/server.ts`:

* [  JavaScript ](#tab-panel-5745)
* [  TypeScript ](#tab-panel-5746)

JavaScript

```
import { Agent, routeAgentRequest, callable } from "agents";
// Define the state shape
// Create the agentexport class CounterAgent extends Agent {  // Initial state for new instances  initialState = { count: 0 };
  // Methods marked with @callable can be called from the client  @callable()  increment() {    this.setState({ count: this.state.count + 1 });    return this.state.count;  }
  @callable()  decrement() {    this.setState({ count: this.state.count - 1 });    return this.state.count;  }
  @callable()  reset() {    this.setState({ count: 0 });  }}
// Route requests to agentsexport default {  async fetch(request, env, ctx) {    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
import { Agent, routeAgentRequest, callable } from "agents";
// Define the state shapeexport type CounterState = {  count: number;};
// Create the agentexport class CounterAgent extends Agent<Env, CounterState> {  // Initial state for new instances  initialState: CounterState = { count: 0 };
  // Methods marked with @callable can be called from the client  @callable()  increment() {    this.setState({ count: this.state.count + 1 });    return this.state.count;  }
  @callable()  decrement() {    this.setState({ count: this.state.count - 1 });    return this.state.count;  }
  @callable()  reset() {    this.setState({ count: 0 });  }}
// Route requests to agentsexport default {  async fetch(request: Request, env: Env, ctx: ExecutionContext) {    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

Update `wrangler.jsonc` to register the agent:

* [  wrangler.jsonc ](#tab-panel-5741)
* [  wrangler.toml ](#tab-panel-5742)

JSONC

```
{  "name": "my-agent",  "main": "src/server.ts",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": ["nodejs_compat"],  "durable_objects": {    "bindings": [      {        "name": "CounterAgent",        "class_name": "CounterAgent",      },    ],  },  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": ["CounterAgent"],    },  ],}
```

TOML

```
name = "my-agent"main = "src/server.ts"# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = [ "nodejs_compat" ]
[[durable_objects.bindings]]name = "CounterAgent"class_name = "CounterAgent"
[[migrations]]tag = "v1"new_sqlite_classes = [ "CounterAgent" ]
```

**Key points:**

* `name` in bindings becomes the property on `env` (for example, `env.CounterAgent`)
* `class_name` must exactly match your exported class name
* `new_sqlite_classes` enables SQLite storage for state persistence
* `nodejs_compat` flag is required for the agents package

## Connect from React

Replace `src/client.tsx`:

src/client.tsx

```
import "./styles.css";import { createRoot } from "react-dom/client";import { useState } from "react";import { useAgent } from "agents/react";import type { CounterAgent, CounterState } from "./server";
export default function App() {  const [count, setCount] = useState(0);
  // Connect to the Counter agent  const agent = useAgent<CounterAgent, CounterState>({    agent: "CounterAgent",    onStateUpdate: (state) => setCount(state.count),  });
  return (    <div style={{ padding: "2rem", fontFamily: "system-ui" }}>      <h1>Counter Agent</h1>      <p style={{ fontSize: "3rem" }}>{count}</p>      <div style={{ display: "flex", gap: "1rem" }}>        <button onClick={() => agent.stub.decrement()}>-</button>        <button onClick={() => agent.stub.reset()}>Reset</button>        <button onClick={() => agent.stub.increment()}>+</button>      </div>    </div>  );}
const root = createRoot(document.getElementById("root")!);root.render(<App />);
```

Key points:

* `useAgent` connects to your agent via WebSocket
* `onStateUpdate` fires whenever the agent's state changes
* `agent.stub.methodName()` calls methods marked with `@callable()` on your agent

## How it works

When you clicked the button:

1. **Client** called `agent.stub.increment()` over WebSocket
2. **Agent** ran `increment()`, updated state with `setState()`
3. **State** persisted to SQLite automatically
4. **Broadcast** sent to all connected clients
5. **React** updated via `onStateUpdate`

flowchart LR
    A["Browser<br/>(React)"] <-->|WebSocket| B["Agent<br/>(Counter)"]
    B --> C["SQLite<br/>(State)"]

### Key concepts

| Concept              | What it means                                                                                     |
| -------------------- | ------------------------------------------------------------------------------------------------- |
| **Agent instance**   | Each unique name gets its own agent. CounterAgent:user-123 is separate from CounterAgent:user-456 |
| **Persistent state** | State survives restarts, deploys, and hibernation. It is stored in SQLite                         |
| **Real-time sync**   | All clients connected to the same agent receive state updates instantly                           |
| **Hibernation**      | When no clients are connected, the agent hibernates (no cost). It wakes on the next request       |

## Connect from vanilla JavaScript

If you are not using React:

* [  JavaScript ](#tab-panel-5743)
* [  TypeScript ](#tab-panel-5744)

JavaScript

```
import { AgentClient } from "agents/client";
const agent = new AgentClient({  agent: "CounterAgent",  name: "my-counter", // optional, defaults to "default"  onStateUpdate: (state) => {    console.log("New count:", state.count);  },});
// Call methodsawait agent.call("increment");await agent.call("reset");
```

TypeScript

```
import { AgentClient } from "agents/client";
const agent = new AgentClient({  agent: "CounterAgent",  name: "my-counter", // optional, defaults to "default"  onStateUpdate: (state) => {    console.log("New count:", state.count);  },});
// Call methodsawait agent.call("increment");await agent.call("reset");
```

## Deploy to Cloudflare

Terminal window

```
npm run deploy
```

Your agent is now live on Cloudflare's global network, running close to your users.

## Common integration patterns

### Agents behind authentication

Check auth before routing to agents:

* [  JavaScript ](#tab-panel-5757)
* [  TypeScript ](#tab-panel-5758)

JavaScript

```
export default {  async fetch(request, env) {    // Check auth for agent routes    if (request.url.includes("/agents/")) {      const authResult = await checkAuth(request, env);      if (!authResult.valid) {        return new Response("Unauthorized", { status: 401 });      }    }
    const agentResponse = await routeAgentRequest(request, env);    if (agentResponse) return agentResponse;
    // ... rest of routing  },};
```

TypeScript

```
export default {  async fetch(request: Request, env: Env) {    // Check auth for agent routes    if (request.url.includes("/agents/")) {      const authResult = await checkAuth(request, env);      if (!authResult.valid) {        return new Response("Unauthorized", { status: 401 });      }    }
    const agentResponse = await routeAgentRequest(request, env);    if (agentResponse) return agentResponse;
    // ... rest of routing  },} satisfies ExportedHandler<Env>;
```

### Custom agent path prefix

By default, agents are routed at `/agents/{agent-name}/{instance-name}`. You can customize this:

* [  JavaScript ](#tab-panel-5751)
* [  TypeScript ](#tab-panel-5752)

JavaScript

```
import { routeAgentRequest } from "agents";
const agentResponse = await routeAgentRequest(request, env, {  prefix: "/api/agents", // Now routes at /api/agents/{agent-name}/{instance-name}});
```

TypeScript

```
import { routeAgentRequest } from "agents";
const agentResponse = await routeAgentRequest(request, env, {  prefix: "/api/agents", // Now routes at /api/agents/{agent-name}/{instance-name}});
```

Refer to [Routing](https://developers.cloudflare.com/agents/runtime/communication/routing/) for more options including CORS, custom instance naming, and location hints.

### Accessing agents from server code

You can interact with agents directly from your Worker code:

* [  JavaScript ](#tab-panel-5761)
* [  TypeScript ](#tab-panel-5762)

JavaScript

```
import { getAgentByName } from "agents";
export default {  async fetch(request, env) {    if (request.url.endsWith("/api/increment")) {      // Get a specific agent instance      const counter = await getAgentByName(env.CounterAgent, "shared-counter");      const newCount = await counter.increment();      return Response.json({ count: newCount });    }    // ...  },};
```

TypeScript

```
import { getAgentByName } from "agents";
export default {  async fetch(request: Request, env: Env) {    if (request.url.endsWith("/api/increment")) {      // Get a specific agent instance      const counter = await getAgentByName(env.CounterAgent, "shared-counter");      const newCount = await counter.increment();      return Response.json({ count: newCount });    }    // ...  },} satisfies ExportedHandler<Env>;
```

### Adding multiple agents

Add more agents by extending the configuration:

* [  JavaScript ](#tab-panel-5755)
* [  TypeScript ](#tab-panel-5756)

JavaScript

```
// src/agents/chat.tsexport class Chat extends Agent {  // ...}
// src/agents/scheduler.tsexport class Scheduler extends Agent {  // ...}
```

TypeScript

```
// src/agents/chat.tsexport class Chat extends Agent {  // ...}
// src/agents/scheduler.tsexport class Scheduler extends Agent {  // ...}
```

Update the Wrangler configuration file:

* [  wrangler.jsonc ](#tab-panel-5747)
* [  wrangler.toml ](#tab-panel-5748)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  "durable_objects": {    "bindings": [      {        "name": "CounterAgent",        "class_name": "CounterAgent"      },      {        "name": "Chat",        "class_name": "Chat"      },      {        "name": "Scheduler",        "class_name": "Scheduler"      }    ]  },  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": [        "CounterAgent",        "Chat",        "Scheduler"      ]    }  ]}
```

TOML

```
[[durable_objects.bindings]]name = "CounterAgent"class_name = "CounterAgent"
[[durable_objects.bindings]]name = "Chat"class_name = "Chat"
[[durable_objects.bindings]]name = "Scheduler"class_name = "Scheduler"
[[migrations]]tag = "v1"new_sqlite_classes = ["CounterAgent", "Chat", "Scheduler"]
```

Export all agents from your entry point:

* [  JavaScript ](#tab-panel-5753)
* [  TypeScript ](#tab-panel-5754)

JavaScript

```
export { CounterAgent } from "./agents/counter";export { Chat } from "./agents/chat";export { Scheduler } from "./agents/scheduler";
```

TypeScript

```
export { CounterAgent } from "./agents/counter";export { Chat } from "./agents/chat";export { Scheduler } from "./agents/scheduler";
```

## Troubleshooting

### Agent not found, or 404 errors

1. **Check the export** \- Agent class must be exported from your main entry point.
2. **Check the binding** \- `class_name` in the Wrangler configuration file must exactly match the exported class name.
3. **Check the route** \- Default route is `/agents/{'{agent-name}'}/{'{instance-name}'}`. Agent name in client matches the class name (case-insensitive).

### No such Durable Object class error

Add the migration to the Wrangler configuration file:

* [  wrangler.jsonc ](#tab-panel-5749)
* [  wrangler.toml ](#tab-panel-5750)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": [        "YourAgentClass"      ]    }  ]}
```

TOML

```
[[migrations]]tag = "v1"new_sqlite_classes = ["YourAgentClass"]
```

### WebSocket connection fails

Ensure your routing passes the response unchanged:

* [  JavaScript ](#tab-panel-5759)
* [  TypeScript ](#tab-panel-5760)

JavaScript

```
// Correct - return the response directlyconst agentResponse = await routeAgentRequest(request, env);if (agentResponse) return agentResponse;
// Wrong - this breaks WebSocket connectionsif (agentResponse) return new Response(agentResponse.body);
```

TypeScript

```
// Correct - return the response directlyconst agentResponse = await routeAgentRequest(request, env);if (agentResponse) return agentResponse;
// Wrong - this breaks WebSocket connectionsif (agentResponse) return new Response(agentResponse.body);
```

### State not persisting

Check that:

1. You are calling `this.setState()`, not mutating `this.state` directly.
2. The agent class is in `new_sqlite_classes` in migrations.
3. You are connecting to the same agent instance name.
4. The `onStateUpdate` callback is wired up in your client.
5. WebSocket connection is established (check browser dev tools).

### "Method X is not callable" errors

Make sure your methods are decorated with `@callable()`:

* [  JavaScript ](#tab-panel-5763)
* [  TypeScript ](#tab-panel-5764)

JavaScript

```
import { Agent, callable } from "agents";
export class MyAgent extends Agent {  @callable()  increment() {    // ...  }}
```

TypeScript

```
import { Agent, callable } from "agents";
export class MyAgent extends Agent {  @callable()  increment() {    // ...  }}
```

### Type errors with `agent.stub`

Add the agent and state type parameters:

* [  JavaScript ](#tab-panel-5765)
* [  TypeScript ](#tab-panel-5766)

JavaScript

```
import { useAgent } from "agents/react";
// Pass the agent and state types to useAgentconst agent = useAgent({  agent: "CounterAgent",  onStateUpdate: (state) => setCount(state.count),});
// Now agent.stub is fully typedagent.stub.increment();
```

TypeScript

```
import { useAgent } from "agents/react";import type { CounterAgent, CounterState } from "./server";
// Pass the agent and state types to useAgentconst agent = useAgent<CounterAgent, CounterState>({  agent: "CounterAgent",  onStateUpdate: (state) => setCount(state.count),});
// Now agent.stub is fully typedagent.stub.increment();
```

### `SyntaxError: Invalid or unexpected token` with `@callable()`

If your dev server fails with `SyntaxError: Invalid or unexpected token`, set `"target": "ES2021"` in your `tsconfig.json`. This ensures that Vite's esbuild transpiler downlevels TC39 decorators instead of passing them through as native syntax.

```
{  "compilerOptions": {    "target": "ES2021"  }}
```

Warning

Do not set `"experimentalDecorators": true` in your `tsconfig.json`. The Agents SDK uses [TC39 standard decorators ↗](https://github.com/tc39/proposal-decorators), not TypeScript legacy decorators. Enabling `experimentalDecorators` applies an incompatible transform that silently breaks `@callable()` at runtime.

## Next steps

Now that you have a working agent, explore these topics:

### Common next steps

| Learn how to             | Refer to                                                                                        |
| ------------------------ | ----------------------------------------------------------------------------------------------- |
| Add AI/LLM capabilities  | [Using AI models](https://developers.cloudflare.com/agents/runtime/operations/using-ai-models/) |
| Expose tools via MCP     | [MCP servers](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/)  |
| Run background tasks     | [Schedule tasks](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/)    |
| Handle emails            | [Email routing](https://developers.cloudflare.com/agents/communication-channels/email/)         |
| Use Cloudflare Workflows | [Run Workflows](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/)      |

### Explore more

[ State management ](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) Deep dive into setState(), initialState, and onStateChanged(). 

[ Client SDK ](https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/) Full useAgent and AgentClient API reference. 

[ Callable methods ](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/) Expose methods to clients with @callable(). 

[ Schedule tasks ](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) Run tasks on a delay, schedule, or cron. 

[ Agent class internals ](https://developers.cloudflare.com/agents/runtime/lifecycle/agent-class/) Full lifecycle and methods reference. 

[ Agents API ](https://developers.cloudflare.com/agents/runtime/agents-api/) Complete API reference for the Agents SDK.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/getting-started/quick-start/#page","headline":"Quick start · Cloudflare Agents docs","description":"Build your first agent in 10 minutes — a counter with persistent state that syncs to a React frontend in real-time.","url":"https://developers.cloudflare.com/agents/getting-started/quick-start/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-09","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/getting-started/","name":"Getting started"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/getting-started/quick-start/","name":"Quick start"}}]}
```

---

---
title: Testing your Agents
description: Write and run tests for Cloudflare Agents using Vitest and the Workers test pool.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Testing your Agents

Because Agents run on Cloudflare Workers and Durable Objects, they can be tested using the same tools and techniques as Workers and Durable Objects.

## Writing and running tests

### Setup

Note

The `agents-starter` template and new Cloudflare Workers projects already include the relevant `vitest` and `@cloudflare/vitest-pool-workers` packages, as well as a valid `vitest.config.js` file.

Before you write your first test, install the necessary packages:

Terminal window

```
npm install vitest@^4.1.0 @cloudflare/vitest-pool-workers --save-dev
```

Ensure that your `vitest.config.js` has the `cloudflareTest` plugin configured:

JavaScript

```
import { cloudflareTest } from "@cloudflare/vitest-pool-workers";import { defineConfig } from "vitest/config";
export default defineConfig({  plugins: [    cloudflareTest({      wrangler: { configPath: "./wrangler.jsonc" },    }),  ],});
```

### Write a test

Note

Review the [Vitest documentation ↗](https://vitest.dev/) for more information on testing, including the test API reference and advanced testing techniques.

Tests use the `vitest` framework. A basic test suite for your Agent can validate how your Agent responds to requests, but can also unit test your Agent's methods and state.

TypeScript

```
import { env, exports } from "cloudflare:workers";import {  createExecutionContext,  waitOnExecutionContext,} from "cloudflare:test";import { describe, it, expect } from "vitest";import worker from "../src";import { Env } from "../src";
interface ProvidedEnv extends Env {}
describe("make a request to my Agent", () => {  // Unit testing approach  it("responds with state", async () => {    // Provide a valid URL that your Worker can use to route to your Agent    // If you are using routeAgentRequest, this will be /agents/:agent/:name    const request = new Request<unknown, IncomingRequestCfProperties>(      "http://example.com/agents/my-agent/agent-123",    );    const ctx = createExecutionContext();    const response = await worker.fetch(request, env, ctx);    await waitOnExecutionContext(ctx);    expect(await response.json()).toEqual({ hello: "from your agent" });  });
  it("also responds with state", async () => {    const request = new Request("http://example.com/agents/my-agent/agent-123");    const response = await exports.default.fetch(request);    expect(await response.json()).toEqual({ hello: "from your agent" });  });});
```

### Run tests

Running tests is done using the `vitest` CLI:

Terminal window

```
npm run test# or run vitest directlynpx vitest
```

```
  MyAgent    ✓ should return a greeting (1 ms)
Test Files  1 passed (1)
```

Review the [documentation on testing](https://developers.cloudflare.com/workers/testing/vitest-integration/write-your-first-test/) for additional examples and test configuration.

## Running Agents locally

You can also run an Agent locally using the `wrangler` CLI:

Terminal window

```
npx wrangler dev
```

```
Your Worker and resources are simulated locally via Miniflare. For more information, see: https://developers.cloudflare.com/workers/testing/local-development.
Your worker has access to the following bindings:- Durable Objects:  - MyAgent: MyAgent  Starting local server...[wrangler:inf] Ready on http://localhost:53645
```

This spins up a local development server that runs the same runtime as Cloudflare Workers, and allows you to iterate on your Agent's code and test it locally without deploying it.

Visit the [wrangler dev ↗](https://developers.cloudflare.com/workers/wrangler/commands/general/#dev) docs to review the CLI flags and configuration options.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/getting-started/testing-your-agent/#page","headline":"Testing your Agents · Cloudflare Agents docs","description":"Write and run tests for Cloudflare Agents using Vitest and the Workers test pool.","url":"https://developers.cloudflare.com/agents/getting-started/testing-your-agent/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-04-30","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/getting-started/","name":"Getting started"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/getting-started/testing-your-agent/","name":"Testing your Agents"}}]}
```

---

---
title: Limits
description: Understand the concurrency, storage, and compute time limits that apply to Cloudflare Agents.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Limits

Limits that apply to authoring, deploying, and running Agents are detailed below.

Many limits are inherited from those applied to Workers scripts and/or Durable Objects, and are detailed in the [Workers limits](https://developers.cloudflare.com/workers/platform/limits/) documentation.

| Feature                                                | Limit                                                                                        |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| Max concurrent (running) Agents per account            | Tens of millions+ [1](#user-content-fn-1)                                                    |
| Max definitions per account                            | \~250,000+ [2](#user-content-fn-2)                                                           |
| Max state stored per unique Agent                      | 1 GB                                                                                         |
| Max compute time per Agent                             | 30 seconds (refreshed per HTTP request / incoming WebSocket message) [3](#user-content-fn-3) |
| Duration (wall clock) per step [3](#user-content-fn-3) | Unlimited (for example, waiting on a database call or an LLM response)                       |

---

Need a higher limit?

To request an adjustment to a limit, complete the [Limit Increase Request Form ↗](https://forms.gle/eX6pXvit1wBv77Yw5). If the limit can be increased, Cloudflare will contact you with next steps.

## Footnotes

1. Yes, really. You can have tens of millions of Agents running concurrently, as each Agent is mapped to a [unique Durable Object](https://developers.cloudflare.com/durable-objects/concepts/what-are-durable-objects/) (actor). [↩](#user-content-fnref-1)
2. You can deploy up to [500 scripts per account](https://developers.cloudflare.com/workers/platform/limits/), but each script (project) can define multiple Agents. Each deployed script can be up to 10 MB on the [Workers Paid Plan](https://developers.cloudflare.com/workers/platform/pricing/#workers) [↩](#user-content-fnref-2)
3. Compute (CPU) time per Agent is limited to 30 seconds, but this is refreshed when an Agent receives a new HTTP request, runs a [scheduled task](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/), or an incoming WebSocket message. [↩](#user-content-fnref-3) [↩2](#user-content-fnref-3-2)

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/platform/limits/#page","headline":"Limits · Cloudflare Agents docs","description":"Understand the concurrency, storage, and compute time limits that apply to Cloudflare Agents.","url":"https://developers.cloudflare.com/agents/platform/limits/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/platform/","name":"Platform"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/platform/limits/","name":"Limits"}}]}
```

---

---
title: Agents API
description: Reference for the Agent base class, lifecycle hooks, SQL storage, and error handling in the Agents SDK.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Agents API

This page provides an overview of the Agents SDK. For detailed documentation on each feature, refer to the linked reference pages.

## Overview

The Agents SDK provides two main APIs:

| API                         | Description                                                                      |
| --------------------------- | -------------------------------------------------------------------------------- |
| **Server-side** Agent class | Encapsulates agent logic: connections, state, methods, AI models, error handling |
| **Client-side** SDK         | AgentClient, useAgent, and useAgentChat for connecting from browsers             |

Note

Agents require [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/). Refer to [Configuration](https://developers.cloudflare.com/agents/runtime/operations/configuration/) to learn how to add the required bindings.

## Agent class

An Agent is a class that extends the base `Agent` class:

TypeScript

```
import { Agent, routeAgentRequest } from "agents";
export class MyAgent extends Agent<Env, State> {  // Your agent logic}
export default {  async fetch(request: Request, env: Env) {    return (      (await routeAgentRequest(request, env)) ||      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

Each Agent can have millions of instances. Each instance is a separate micro-server that runs independently, allowing horizontal scaling. Instances are addressed by a unique identifier (user ID, email, ticket number, etc.).

Note

An instance of an Agent is globally unique: given the same name (or ID), you will always get the same instance of an agent.

This allows you to avoid synchronizing state across requests: if an Agent instance represents a specific user, team, channel or other entity, you can use the Agent instance to store state for that entity. There is no need to set up a centralized session store.

If the client disconnects, you can always route the client back to the exact same Agent and pick up where they left off.

## Lifecycle

flowchart TD
    A["onStart<br/>(instance wakes up)"] --> B["onRequest<br/>(HTTP)"]
    A --> C["onConnect<br/>(WebSocket)"]
    A --> D["onEmail"]
    C --> E["onMessage ↔ send()<br/>onError (on failure)"]
    E --> F["onClose"]

| Method                                      | When it runs                                                                                                                                                                                                                 |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| onStart(props?)                             | When the instance starts, or wakes from hibernation. Receives optional [initialization props](https://developers.cloudflare.com/agents/runtime/communication/routing/#props) passed via getAgentByName or routeAgentRequest. |
| onRequest(request)                          | For each HTTP request to the instance                                                                                                                                                                                        |
| onConnect(connection, ctx)                  | When a WebSocket connection is established                                                                                                                                                                                   |
| onMessage(connection, message)              | For each WebSocket message received                                                                                                                                                                                          |
| onError(connection, error)                  | When a WebSocket error occurs                                                                                                                                                                                                |
| onClose(connection, code, reason, wasClean) | When a WebSocket connection closes                                                                                                                                                                                           |
| onEmail(email)                              | When an email is routed to the instance                                                                                                                                                                                      |
| onStateChanged(state, source)               | When state changes (from server or client)                                                                                                                                                                                   |

## Core properties

| Property   | Type             | Description                            |
| ---------- | ---------------- | -------------------------------------- |
| this.env   | Env              | Environment variables and bindings     |
| this.ctx   | ExecutionContext | Execution context for the request      |
| this.state | State            | Current persisted state                |
| this.sql   | Function         | Execute SQL queries on embedded SQLite |

## Server-side API reference

| Feature               | Methods                                                                              | Documentation                                                                                          |
| --------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
| **State**             | setState(), onStateChanged(), initialState                                           | [Store and sync state](https://developers.cloudflare.com/agents/runtime/lifecycle/state/)              |
| **Callable methods**  | @callable() decorator                                                                | [Callable methods](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/)       |
| **Scheduling**        | schedule(), scheduleEvery(), getScheduleById(), listSchedules()                      | [Schedule tasks](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/)           |
| **Durable execution** | runFiber(), startFiber(), stash(), onFiberRecovered(), keepAlive(), keepAliveWhile() | [Durable execution](https://developers.cloudflare.com/agents/runtime/execution/durable-execution/)     |
| **Queue**             | queue(), dequeue(), dequeueAll(), getQueue()                                         | [Queue tasks](https://developers.cloudflare.com/agents/runtime/execution/queue-tasks/)                 |
| **WebSockets**        | onConnect(), onMessage(), onClose(), broadcast()                                     | [WebSockets](https://developers.cloudflare.com/agents/runtime/communication/websockets/)               |
| **HTTP/SSE**          | onRequest()                                                                          | [HTTP and SSE](https://developers.cloudflare.com/agents/runtime/communication/http-sse/)               |
| **Email**             | onEmail(), replyToEmail()                                                            | [Email routing](https://developers.cloudflare.com/agents/communication-channels/email/)                |
| **Workflows**         | runWorkflow(), waitForApproval()                                                     | [Run Workflows](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/)             |
| **MCP Client**        | addMcpServer(), removeMcpServer(), getMcpServers()                                   | [MCP Client API](https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/)     |
| **AI Models**         | Workers AI, OpenAI, Anthropic bindings                                               | [Using AI models](https://developers.cloudflare.com/agents/runtime/operations/using-ai-models/)        |
| **Protocol messages** | shouldSendProtocolMessages(), isConnectionProtocolEnabled()                          | [Protocol messages](https://developers.cloudflare.com/agents/runtime/communication/protocol-messages/) |
| **Context**           | getCurrentAgent()                                                                    | [getCurrentAgent()](https://developers.cloudflare.com/agents/runtime/lifecycle/get-current-agent/)     |
| **Observability**     | subscribe(), diagnostics channels, Tail Workers                                      | [Observability](https://developers.cloudflare.com/agents/runtime/operations/observability/)            |
| **Sub-agents**        | subAgent(), abortSubAgent(), deleteSubAgent()                                        | [Sub-agents](https://developers.cloudflare.com/agents/runtime/execution/sub-agents/)                   |
| **Agents as tools**   | runAgentTool(), clearAgentToolRuns(), hasAgentToolRun()                              | [Agents as tools](https://developers.cloudflare.com/agents/runtime/execution/agent-tools/)             |
| **Agent Skills**      | skills registry, bundled skill sources, script runners                               | [Agent Skills](https://developers.cloudflare.com/agents/runtime/execution/agent-skills/)               |
| **Sessions**          | Session.create(), context blocks, compaction, search                                 | [Sessions](https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/)                       |
| **Think**             | Think base class, workspace tools, lifecycle hooks, extensions                       | [Think](https://developers.cloudflare.com/agents/harnesses/think/)                                     |
| **Chat SDK**          | createChatSdkState(), ChatSdkStateAgent                                              | [Chat SDK](https://developers.cloudflare.com/agents/runtime/communication/chat-sdk/)                   |

## SQL API

Each Agent instance has an embedded SQLite database accessed via `this.sql`:

TypeScript

```
// Create tablesthis.sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT)`;
// Insert datathis.sql`INSERT INTO users (id, name) VALUES (${id}, ${name})`;
// Query dataconst users = this.sql<User>`SELECT * FROM users WHERE id = ${id}`;
```

For state that needs to sync with clients, use the [State API](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) instead.

## Client-side API reference

| Feature               | Methods              | Documentation                                                                                                                |
| --------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| **WebSocket client**  | AgentClient          | [Client SDK](https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/)                               |
| **HTTP client**       | agentFetch()         | [Client SDK](https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/#http-requests-with-agentfetch) |
| **React hook**        | useAgent()           | [Client SDK](https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/#react)                         |
| **Chat hook**         | useAgentChat()       | [Client SDK](https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/)                               |
| **Agent tool events** | useAgentToolEvents() | [Agents as tools](https://developers.cloudflare.com/agents/runtime/execution/agent-tools/#render-child-timelines-in-react)   |

Module-level helper exports include `agentTool()` from `agents/agent-tools`, which converts a Think or `AIChatAgent` subclass into an AI SDK tool definition.

### Quick example

TypeScript

```
import { useAgent } from "agents/react";import type { MyAgent } from "./server";
function App() {  const agent = useAgent<MyAgent, State>({    agent: "my-agent",    name: "user-123",  });
  // Call methods on the agent  agent.stub.someMethod();
  // Update state (syncs to server and all clients)  agent.setState({ count: 1 });}
```

## Chat agents

For AI chat applications, extend `AIChatAgent` instead of `Agent`:

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";
class ChatAgent extends AIChatAgent {  async onChatMessage(onFinish) {    // this.messages contains the conversation history    // Return a streaming response  }}
```

Features include:

* Built-in message persistence
* Automatic resumable streaming (reconnect mid-stream)
* Works with `useAgentChat` React hook

Refer to [Build a chat agent](https://developers.cloudflare.com/agents/examples/chat-agent/) for a complete tutorial.

## Routing

Agents are accessed via URL patterns:

```
https://your-worker.workers.dev/agents/:agent-name/:instance-name
```

Use `routeAgentRequest()` in your Worker to route requests:

TypeScript

```
import { routeAgentRequest } from "agents";
export default {  async fetch(request: Request, env: Env) {    return (      routeAgentRequest(request, env) ||      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

Refer to [Routing](https://developers.cloudflare.com/agents/runtime/communication/routing/) for custom paths, CORS, and instance naming patterns.

## Next steps

[ Quick start ](https://developers.cloudflare.com/agents/getting-started/quick-start/) Build your first agent in about 10 minutes. 

[ Configuration ](https://developers.cloudflare.com/agents/runtime/operations/configuration/) Learn about wrangler.jsonc setup and deployment. 

[ WebSockets ](https://developers.cloudflare.com/agents/runtime/communication/websockets/) Real-time bidirectional communication with clients. 

[ Build a chat agent ](https://developers.cloudflare.com/agents/examples/chat-agent/) Build AI applications with AIChatAgent.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/agents-api/#page","headline":"Agents API · Cloudflare Agents docs","description":"Reference for the Agent base class, lifecycle hooks, SQL storage, and error handling in the Agents SDK.","url":"https://developers.cloudflare.com/agents/runtime/agents-api/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-09","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/agents-api/","name":"Agents API"}}]}
```

---

---
title: Chat SDK
description: Integrate Chat SDK with Agents, including durable state for subscriptions, locks, queues, and message history.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Chat SDK

Use `agents/chat-sdk` when you run the [Chat SDK ↗](https://chat-sdk.dev/) inside an Agent. The first integration helper is a Chat SDK `StateAdapter` that stores state in Agents sub-agents.

The adapter stores Chat SDK subscriptions, locks, queues, dedupe keys, thread state, channel state, callback metadata, transcript lists, and thread history in Durable Object SQLite. Each state shard is a `ChatSdkStateAgent` sub-agent under your ingress Agent.

## Install

Install both packages in the Worker that hosts your messenger ingress:

 npm  yarn  pnpm  bun 

```
npm i agents chat
```

```
yarn add agents chat
```

```
pnpm add agents chat
```

```
bun add agents chat
```

`agents/chat-sdk` provides durable state for Chat SDK. Use it with any Chat SDK adapter, such as Telegram, Slack, Discord, Teams, or Google Chat.

## Basic setup

Create a parent Agent that owns your Chat SDK runtime. Pass `createChatSdkState()` as the Chat SDK `state` option.

* [  JavaScript ](#tab-panel-6097)
* [  TypeScript ](#tab-panel-6098)

JavaScript

```
import { Agent } from "agents";import { createChatSdkState } from "agents/chat-sdk";import { Chat } from "chat";import { createTelegramAdapter } from "@chat-adapter/telegram";
export { ChatSdkStateAgent } from "agents/chat-sdk";
export class MessengerAgent extends Agent {  chat;
  onStart() {    const telegram = createTelegramAdapter({      botToken: this.env.TELEGRAM_BOT_TOKEN,      mode: "webhook",      userName: "my_bot",    });
    this.chat = new Chat({      adapters: { telegram },      userName: "my_bot",      state: createChatSdkState(),      concurrency: { strategy: "burst", debounceMs: 600 },    });  }}
```

TypeScript

```
import { Agent } from "agents";import { createChatSdkState } from "agents/chat-sdk";import { Chat } from "chat";import { createTelegramAdapter } from "@chat-adapter/telegram";
export { ChatSdkStateAgent } from "agents/chat-sdk";
export class MessengerAgent extends Agent<Env> {  private chat!: Chat;
  onStart() {    const telegram = createTelegramAdapter({      botToken: this.env.TELEGRAM_BOT_TOKEN,      mode: "webhook",      userName: "my_bot",    });
    this.chat = new Chat({      adapters: { telegram },      userName: "my_bot",      state: createChatSdkState(),      concurrency: { strategy: "burst", debounceMs: 600 },    });  }}
```

Add the parent Agent to your Durable Object migration:

* [  wrangler.jsonc ](#tab-panel-6087)
* [  wrangler.toml ](#tab-panel-6088)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": [    "nodejs_compat"  ],  "durable_objects": {    "bindings": [      {        "class_name": "MessengerAgent",        "name": "MessengerAgent"      }    ]  },  "migrations": [    {      "new_sqlite_classes": [        "MessengerAgent"      ],      "tag": "v1"    }  ]}
```

TOML

```
# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = ["nodejs_compat"]
[[durable_objects.bindings]]class_name = "MessengerAgent"name = "MessengerAgent"
[[migrations]]new_sqlite_classes = ["MessengerAgent"]tag = "v1"
```

Export `ChatSdkStateAgent` from your Worker entry point so sub-agent routing can resolve it. When `createChatSdkState()` is called inside an Agent lifecycle method or request handler, it uses the current Agent as the parent and creates state shards with `this.subAgent()`.

## State sharding

By default, Chat SDK state is sharded by the first two colon-separated segments of a thread-like key.

For example, `telegram:-100123:456` and `telegram:-100123:789` share the same state shard, `telegram:-100123`.

The default key sharder recognizes these Chat SDK key prefixes:

* `thread-state:`
* `channel-state:`
* `msg-history:`
* `transcripts:user:`

Unknown keys use the adapter's default shard name, `default`.

## Custom sharding

Use `shardKey` to control how thread IDs map to state sub-agent names:

* [  JavaScript ](#tab-panel-6089)
* [  TypeScript ](#tab-panel-6090)

JavaScript

```
const state = createChatSdkState({  shardKey(threadId) {    return threadId.split(":").slice(0, 2).join(":");  },});
```

TypeScript

```
const state = createChatSdkState({  shardKey(threadId) {    return threadId.split(":").slice(0, 2).join(":");  },});
```

Use `keyShard` when an adapter stores non-thread-shaped keys that should still route to a provider-specific shard:

* [  JavaScript ](#tab-panel-6095)
* [  TypeScript ](#tab-panel-6096)

JavaScript

```
const state = createChatSdkState({  keyShard(key) {    if (!key.startsWith("dedupe:telegram:")) {      return undefined;    }
    const chatId = key.slice("dedupe:telegram:".length).split(":")[0];    return chatId ? `telegram:${chatId}` : undefined;  },});
```

TypeScript

```
const state = createChatSdkState({  keyShard(key) {    if (!key.startsWith("dedupe:telegram:")) {      return undefined;    }
    const chatId = key.slice("dedupe:telegram:".length).split(":")[0];    return chatId ? `telegram:${chatId}` : undefined;  },});
```

Returning `undefined` falls back to the built-in key sharder and then to the default shard.

## API

### `createChatSdkState(options)`

Creates a Chat SDK `StateAdapter` backed by a `ChatSdkStateAgent` sub-agent.

* [  JavaScript ](#tab-panel-6093)
* [  TypeScript ](#tab-panel-6094)

JavaScript

```
import { createChatSdkState } from "agents/chat-sdk";
export { ChatSdkStateAgent } from "agents/chat-sdk";
const state = createChatSdkState({  // parent: this // Optional. Defaults to the current Agent from getCurrentAgent().});
```

TypeScript

```
import { createChatSdkState } from "agents/chat-sdk";
export { ChatSdkStateAgent } from "agents/chat-sdk";
const state = createChatSdkState({  // parent: this // Optional. Defaults to the current Agent from getCurrentAgent().});
```

Options:

| Option   | Description                                                                                                                   |
| -------- | ----------------------------------------------------------------------------------------------------------------------------- |
| agent    | Optional custom subclass of ChatSdkStateAgent. Defaults to ChatSdkStateAgent.                                                 |
| parent   | Optional parent Agent that will call subAgent() to create state shards. Defaults to the current Agent from getCurrentAgent(). |
| name     | Default shard name for keys that cannot be mapped. Defaults to default.                                                       |
| shardKey | Maps Chat SDK thread IDs and lock keys to a shard name.                                                                       |
| keyShard | Maps generic Chat SDK cache or list keys to a shard name.                                                                     |

### `ChatSdkStateAgent`

The sub-agent class that stores state in SQLite. Export it from your Worker entry point so the runtime can create it.

* [  JavaScript ](#tab-panel-6091)
* [  TypeScript ](#tab-panel-6092)

JavaScript

```
export { ChatSdkStateAgent } from "agents/chat-sdk";
```

TypeScript

```
export { ChatSdkStateAgent } from "agents/chat-sdk";
```

### `ChatSdkStateAdapter`

The concrete `StateAdapter` implementation returned by `createChatSdkState()`. Most applications do not need to instantiate it directly.

## What is stored

The adapter implements the full Chat SDK `StateAdapter` interface:

* Subscriptions for `thread.subscribe()` and `thread.unsubscribe()`.
* Locks for per-thread or per-channel concurrency.
* Pending message queues for `queue`, `debounce`, and `burst` concurrency strategies.
* Generic key-value cache entries with optional TTL.
* Append-only lists with max-length trimming and list-level TTL refresh.

Chat SDK features built on these primitives include:

* Message deduplication.
* Thread and channel state.
* Persistent thread history for adapters that opt in to `persistThreadHistory`.
* Callback URL token storage.
* Modal context storage.
* Cross-platform transcripts.

## Cleanup behavior

TTL reads are strict: expired locks, cache values, queue entries, and list entries are ignored or deleted before they are returned.

Physical cleanup is lazy. `ChatSdkStateAgent` schedules one cleanup callback for the earliest known expiry and reschedules after cleanup runs. This keeps idle shards quiet while preventing expired rows from accumulating indefinitely.

## Example

[ Chat SDK messenger example ](https://github.com/cloudflare/agents/tree/main/examples/chat-sdk-messenger) Build a Telegram messenger bot with Chat SDK state in sub-agents, burst/debounce concurrency, and Think-backed AI replies running in managed fibers.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/communication/chat-sdk/#page","headline":"Chat SDK · Cloudflare Agents docs","description":"Integrate Chat SDK with Agents, including durable state for subscriptions, locks, queues, and message history.","url":"https://developers.cloudflare.com/agents/runtime/communication/chat-sdk/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-09","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/communication/","name":"Communication"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/communication/chat-sdk/","name":"Chat SDK"}}]}
```

---

---
title: HTTP and Server-Sent Events
description: Handle HTTP requests and stream responses with Server-Sent Events (SSE) from Cloudflare Agents.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# HTTP and Server-Sent Events

Agents can handle HTTP requests and stream responses using Server-Sent Events (SSE). This page covers the `onRequest` method and SSE patterns.

## Handling HTTP requests

Define the `onRequest` method to handle HTTP requests to your agent:

* [  JavaScript ](#tab-panel-6101)
* [  TypeScript ](#tab-panel-6102)

JavaScript

```
import { Agent } from "agents";
export class APIAgent extends Agent {  async onRequest(request) {    const url = new URL(request.url);
    // Route based on path    if (url.pathname.endsWith("/status")) {      return Response.json({ status: "ok", state: this.state });    }
    if (url.pathname.endsWith("/action")) {      if (request.method !== "POST") {        return new Response("Method not allowed", { status: 405 });      }      const data = await request.json();      await this.processAction(data.action);      return Response.json({ success: true });    }
    return new Response("Not found", { status: 404 });  }
  async processAction(action) {    // Handle the action  }}
```

TypeScript

```
import { Agent } from "agents";
export class APIAgent extends Agent {  async onRequest(request: Request): Promise<Response> {    const url = new URL(request.url);
    // Route based on path    if (url.pathname.endsWith("/status")) {      return Response.json({ status: "ok", state: this.state });    }
    if (url.pathname.endsWith("/action")) {      if (request.method !== "POST") {        return new Response("Method not allowed", { status: 405 });      }      const data = await request.json<{ action: string }>();      await this.processAction(data.action);      return Response.json({ success: true });    }
    return new Response("Not found", { status: 404 });  }
  async processAction(action: string) {    // Handle the action  }}
```

## Server-Sent Events (SSE)

SSE allows you to stream data to clients over a long-running HTTP connection. This is ideal for AI model responses that generate tokens incrementally.

### Manual SSE

Create an SSE stream manually using `ReadableStream`:

* [  JavaScript ](#tab-panel-6105)
* [  TypeScript ](#tab-panel-6106)

JavaScript

```
export class StreamAgent extends Agent {  async onRequest(request) {    const encoder = new TextEncoder();
    const stream = new ReadableStream({      async start(controller) {        // Send events        controller.enqueue(encoder.encode("data: Starting...\n\n"));
        for (let i = 1; i <= 5; i++) {          await new Promise((r) => setTimeout(r, 500));          controller.enqueue(encoder.encode(`data: Step ${i} complete\n\n`));        }
        controller.enqueue(encoder.encode("data: Done!\n\n"));        controller.close();      },    });
    return new Response(stream, {      headers: {        "Content-Type": "text/event-stream",        "Cache-Control": "no-cache",        Connection: "keep-alive",      },    });  }}
```

TypeScript

```
export class StreamAgent extends Agent {  async onRequest(request: Request): Promise<Response> {    const encoder = new TextEncoder();
    const stream = new ReadableStream({      async start(controller) {        // Send events        controller.enqueue(encoder.encode("data: Starting...\n\n"));
        for (let i = 1; i <= 5; i++) {          await new Promise((r) => setTimeout(r, 500));          controller.enqueue(encoder.encode(`data: Step ${i} complete\n\n`));        }
        controller.enqueue(encoder.encode("data: Done!\n\n"));        controller.close();      },    });
    return new Response(stream, {      headers: {        "Content-Type": "text/event-stream",        "Cache-Control": "no-cache",        Connection: "keep-alive",      },    });  }}
```

### SSE message format

SSE messages follow a specific format:

```
data: your message here\n\n
```

You can also include event types and IDs:

```
event: update\nid: 123\ndata: {"count": 42}\n\n
```

### With AI SDK

The [AI SDK ↗](https://ai-sdk.dev/) provides built-in SSE streaming:

* [  JavaScript ](#tab-panel-6099)
* [  TypeScript ](#tab-panel-6100)

JavaScript

```
import { Agent } from "agents";import { streamText } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class ChatAgent extends Agent {  async onRequest(request) {    const { prompt } = await request.json();
    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: prompt,    });
    return result.toTextStreamResponse();  }}
```

TypeScript

```
import { Agent } from "agents";import { streamText } from "ai";import { createWorkersAI } from "workers-ai-provider";
interface Env {  AI: Ai;}
export class ChatAgent extends Agent<Env> {  async onRequest(request: Request): Promise<Response> {    const { prompt } = await request.json<{ prompt: string }>();
    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: prompt,    });
    return result.toTextStreamResponse();  }}
```

## Connection handling

SSE connections can be long-lived. Handle client disconnects gracefully:

* **Persist progress** — Write to [agent state](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) so clients can resume
* **Use agent routing** — Clients can [reconnect to the same agent instance](https://developers.cloudflare.com/agents/runtime/communication/routing/) without session stores
* **No timeout limits** — Cloudflare Workers have no effective limit on SSE response duration

* [  JavaScript ](#tab-panel-6103)
* [  TypeScript ](#tab-panel-6104)

JavaScript

```
export class ResumeAgent extends Agent {  async onRequest(request) {    const url = new URL(request.url);    const lastEventId = request.headers.get("Last-Event-ID");
    if (lastEventId) {      // Client is resuming - send events after lastEventId      return this.resumeStream(lastEventId);    }
    return this.startStream();  }
  async startStream() {    // Start new stream, saving progress to this.state  }
  async resumeStream(fromId) {    // Resume from saved state  }}
```

TypeScript

```
export class ResumeAgent extends Agent {  async onRequest(request: Request): Promise<Response> {    const url = new URL(request.url);    const lastEventId = request.headers.get("Last-Event-ID");
    if (lastEventId) {      // Client is resuming - send events after lastEventId      return this.resumeStream(lastEventId);    }
    return this.startStream();  }
  async startStream(): Promise<Response> {    // Start new stream, saving progress to this.state  }
  async resumeStream(fromId: string): Promise<Response> {    // Resume from saved state  }}
```

## WebSockets vs SSE

| Feature      | WebSockets             | SSE                                |
| ------------ | ---------------------- | ---------------------------------- |
| Direction    | Bi-directional         | Server → Client only               |
| Protocol     | ws:// / wss://         | HTTP                               |
| Binary data  | Yes                    | No (text only)                     |
| Reconnection | Manual                 | Automatic (browser)                |
| Best for     | Interactive apps, chat | Streaming responses, notifications |

**Recommendation:** Use WebSockets for interactive applications. Use SSE for streaming AI responses or server-push notifications.

Refer to [WebSockets](https://developers.cloudflare.com/agents/runtime/communication/websockets/) for WebSocket documentation.

## Next steps

[ WebSockets ](https://developers.cloudflare.com/agents/runtime/communication/websockets/) Bi-directional real-time communication. 

[ State management ](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) Persist stream progress and agent state. 

[ Build a chat agent ](https://developers.cloudflare.com/agents/examples/chat-agent/) Streaming responses with AI chat.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/communication/http-sse/#page","headline":"HTTP and Server-Sent Events · Cloudflare Agents docs","description":"Handle HTTP requests and stream responses with Server-Sent Events (SSE) from Cloudflare Agents.","url":"https://developers.cloudflare.com/agents/runtime/communication/http-sse/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/communication/","name":"Communication"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/communication/http-sse/","name":"HTTP and Server-Sent Events"}}]}
```

---

---
title: Protocol messages
description: Control the identity, state, and MCP protocol messages sent to WebSocket clients on Agent connect.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Protocol messages

When a WebSocket client connects to an Agent, the framework automatically sends several JSON text frames — identity, state, and MCP server lists. You can suppress these per-connection protocol messages for clients that cannot handle them.

## Overview

On every new connection, the Agent sends three protocol messages:

| Message type            | Content                   |
| ----------------------- | ------------------------- |
| cf\_agent\_identity     | Agent name and class      |
| cf\_agent\_state        | Current agent state       |
| cf\_agent\_mcp\_servers | Connected MCP server list |

State and MCP messages are also broadcast to all connections whenever they change.

For most web clients this is fine — the [Client SDK](https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/) and `useAgent` hook consume these messages automatically. However, some clients cannot handle JSON text frames:

* **Binary-only clients** — MQTT devices, IoT sensors, custom binary protocols
* **Lightweight clients** — Embedded systems with minimal WebSocket stacks
* **Non-browser clients** — Hardware devices connecting via WebSocket

For these connections, you can suppress protocol messages while keeping everything else (RPC, regular messages, broadcasts via `this.broadcast()`) working normally.

## Suppressing protocol messages

Override `shouldSendProtocolMessages` to control which connections receive protocol messages. Return `false` to suppress them.

* [  JavaScript ](#tab-panel-6107)
* [  TypeScript ](#tab-panel-6108)

JavaScript

```
import { Agent } from "agents";
export class IoTAgent extends Agent {  shouldSendProtocolMessages(connection, ctx) {    const url = new URL(ctx.request.url);    return url.searchParams.get("protocol") !== "false";  }}
```

TypeScript

```
import { Agent, type Connection, type ConnectionContext } from "agents";
export class IoTAgent extends Agent<Env, State> {  shouldSendProtocolMessages(    connection: Connection,    ctx: ConnectionContext,  ): boolean {    const url = new URL(ctx.request.url);    return url.searchParams.get("protocol") !== "false";  }}
```

This hook runs during `onConnect`, before any messages are sent. When it returns `false`:

* No `cf_agent_identity`, `cf_agent_state`, or `cf_agent_mcp_servers` messages are sent on connect
* The connection is excluded from state and MCP broadcasts going forward
* RPC calls, regular `onMessage` handling, and `this.broadcast()` still work normally

### Using WebSocket subprotocol

You can also check the WebSocket subprotocol header, which is the standard way to negotiate protocols over WebSocket:

* [  JavaScript ](#tab-panel-6109)
* [  TypeScript ](#tab-panel-6110)

JavaScript

```
export class MqttAgent extends Agent {  shouldSendProtocolMessages(connection, ctx) {    // MQTT-over-WebSocket clients negotiate via subprotocol    const subprotocol = ctx.request.headers.get("Sec-WebSocket-Protocol");    return subprotocol !== "mqtt";  }}
```

TypeScript

```
export class MqttAgent extends Agent<Env, State> {  shouldSendProtocolMessages(    connection: Connection,    ctx: ConnectionContext,  ): boolean {    // MQTT-over-WebSocket clients negotiate via subprotocol    const subprotocol = ctx.request.headers.get("Sec-WebSocket-Protocol");    return subprotocol !== "mqtt";  }}
```

## Checking protocol status

Use `isConnectionProtocolEnabled` to check whether a connection has protocol messages enabled:

* [  JavaScript ](#tab-panel-6111)
* [  TypeScript ](#tab-panel-6112)

JavaScript

```
export class MyAgent extends Agent {  @callable()  async getConnectionInfo() {    const { connection } = getCurrentAgent();    if (!connection) return null;
    return {      protocolEnabled: this.isConnectionProtocolEnabled(connection),      readonly: this.isConnectionReadonly(connection),    };  }}
```

TypeScript

```
export class MyAgent extends Agent<Env, State> {  @callable()  async getConnectionInfo() {    const { connection } = getCurrentAgent();    if (!connection) return null;
    return {      protocolEnabled: this.isConnectionProtocolEnabled(connection),      readonly: this.isConnectionReadonly(connection),    };  }}
```

## What is and is not suppressed

The following table shows what still works when protocol messages are suppressed for a connection:

| Action                                                    | Works? |
| --------------------------------------------------------- | ------ |
| Receive cf\_agent\_identity on connect                    | **No** |
| Receive cf\_agent\_state on connect and broadcasts        | **No** |
| Receive cf\_agent\_mcp\_servers on connect and broadcasts | **No** |
| Send and receive regular WebSocket messages               | Yes    |
| Call @callable() RPC methods                              | Yes    |
| Receive this.broadcast() messages                         | Yes    |
| Send binary data                                          | Yes    |
| Mutate agent state via RPC                                | Yes    |

## Combining with readonly

A connection can be both readonly and protocol-suppressed. This is useful for binary devices that should observe but not modify state:

* [  JavaScript ](#tab-panel-6113)
* [  TypeScript ](#tab-panel-6114)

JavaScript

```
export class SensorHub extends Agent {  shouldSendProtocolMessages(connection, ctx) {    const url = new URL(ctx.request.url);    // Binary sensors don't handle JSON protocol frames    return url.searchParams.get("type") !== "sensor";  }
  shouldConnectionBeReadonly(connection, ctx) {    const url = new URL(ctx.request.url);    // Sensors can only report data via RPC, not modify shared state    return url.searchParams.get("type") === "sensor";  }
  @callable()  async reportReading(sensorId, value) {    // This RPC still works for readonly+no-protocol connections    // because it writes to SQL, not agent state    this      .sql`INSERT INTO readings (sensor_id, value, ts) VALUES (${sensorId}, ${value}, ${Date.now()})`;  }}
```

TypeScript

```
export class SensorHub extends Agent<Env, SensorState> {  shouldSendProtocolMessages(    connection: Connection,    ctx: ConnectionContext,  ): boolean {    const url = new URL(ctx.request.url);    // Binary sensors don't handle JSON protocol frames    return url.searchParams.get("type") !== "sensor";  }
  shouldConnectionBeReadonly(    connection: Connection,    ctx: ConnectionContext,  ): boolean {    const url = new URL(ctx.request.url);    // Sensors can only report data via RPC, not modify shared state    return url.searchParams.get("type") === "sensor";  }
  @callable()  async reportReading(sensorId: string, value: number) {    // This RPC still works for readonly+no-protocol connections    // because it writes to SQL, not agent state    this      .sql`INSERT INTO readings (sensor_id, value, ts) VALUES (${sensorId}, ${value}, ${Date.now()})`;  }}
```

Both flags are stored in the connection's WebSocket attachment and hidden from `connection.state` — they do not interfere with each other or with user-defined connection state.

## API reference

### `shouldSendProtocolMessages`

An overridable hook that determines if a connection should receive protocol messages when it connects.

| Parameter   | Type              | Description                         |
| ----------- | ----------------- | ----------------------------------- |
| connection  | Connection        | The connecting client               |
| ctx         | ConnectionContext | Contains the upgrade request        |
| **Returns** | boolean           | false to suppress protocol messages |

Default: returns `true` (all connections receive protocol messages).

This hook is evaluated once on connect. The result is persisted in the connection's WebSocket attachment and survives [hibernation](https://developers.cloudflare.com/agents/runtime/communication/websockets/#hibernation).

### `isConnectionProtocolEnabled`

Check if a connection currently has protocol messages enabled.

| Parameter   | Type       | Description                           |
| ----------- | ---------- | ------------------------------------- |
| connection  | Connection | The connection to check               |
| **Returns** | boolean    | true if protocol messages are enabled |

Safe to call at any time, including after the agent wakes from hibernation.

## How it works

Protocol status is stored as an internal flag in the connection's WebSocket attachment — the same mechanism used by [readonly connections](https://developers.cloudflare.com/agents/runtime/communication/readonly-connections/). This means:

* **Survives hibernation** — the flag is serialized and restored when the agent wakes up
* **No cleanup needed** — connection state is automatically discarded when the connection closes
* **Zero overhead** — no database tables or queries, just the connection's built-in attachment
* **Safe from user code** — `connection.state` and `connection.setState()` never expose or overwrite the flag

Unlike [readonly](https://developers.cloudflare.com/agents/runtime/communication/readonly-connections/) which can be toggled dynamically with `setConnectionReadonly()`, protocol status is set once on connect and cannot be changed afterward. To change a connection's protocol status, the client must disconnect and reconnect.

## Related resources

* [Readonly connections](https://developers.cloudflare.com/agents/runtime/communication/readonly-connections/)
* [WebSockets](https://developers.cloudflare.com/agents/runtime/communication/websockets/)
* [Store and sync state](https://developers.cloudflare.com/agents/runtime/lifecycle/state/)
* [MCP Client API](https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/)

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/communication/protocol-messages/#page","headline":"Protocol messages · Cloudflare Agents docs","description":"Control the identity, state, and MCP protocol messages sent to WebSocket clients on Agent connect.","url":"https://developers.cloudflare.com/agents/runtime/communication/protocol-messages/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/communication/","name":"Communication"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/communication/protocol-messages/","name":"Protocol messages"}}]}
```

---

---
title: Readonly connections
description: Restrict WebSocket clients to view-only access so they receive state updates without modifying Agent state.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Readonly connections

Readonly connections restrict certain WebSocket clients from modifying agent state while still letting them receive state updates and call non-mutating RPC methods.

## Overview

When a connection is marked as readonly:

* It **receives** state updates from the server
* It **can call** RPC methods that do not modify state
* It **cannot** call `this.setState()` — neither via client-side `setState()` nor via a `@callable()` method that calls `this.setState()` internally

This is useful for scenarios like:

* **View-only modes**: Users who should only observe but not modify
* **Role-based access**: Restricting state modifications based on user roles
* **Multi-tenant scenarios**: Some tenants have read-only access
* **Audit and monitoring connections**: Observers that should not affect the system

* [  JavaScript ](#tab-panel-6115)
* [  TypeScript ](#tab-panel-6116)

JavaScript

```
import { Agent } from "agents";
export class DocAgent extends Agent {  shouldConnectionBeReadonly(connection, ctx) {    const url = new URL(ctx.request.url);    return url.searchParams.get("mode") === "view";  }}
```

TypeScript

```
import { Agent, type Connection, type ConnectionContext } from "agents";
export class DocAgent extends Agent<Env, DocState> {  shouldConnectionBeReadonly(connection: Connection, ctx: ConnectionContext) {    const url = new URL(ctx.request.url);    return url.searchParams.get("mode") === "view";  }}
```

* [  JavaScript ](#tab-panel-6117)
* [  TypeScript ](#tab-panel-6118)

JavaScript

```
// Client - view-only modeconst agent = useAgent({  agent: "DocAgent",  name: "doc-123",  query: { mode: "view" },  onStateUpdateError: (error) => {    toast.error("You're in view-only mode");  },});
```

TypeScript

```
// Client - view-only modeconst agent = useAgent({  agent: "DocAgent",  name: "doc-123",  query: { mode: "view" },  onStateUpdateError: (error) => {    toast.error("You're in view-only mode");  },});
```

## Marking connections as readonly

### On connect

Override `shouldConnectionBeReadonly` to evaluate each connection when it first connects. Return `true` to mark it readonly.

* [  JavaScript ](#tab-panel-6121)
* [  TypeScript ](#tab-panel-6122)

JavaScript

```
export class MyAgent extends Agent {  shouldConnectionBeReadonly(connection, ctx) {    const url = new URL(ctx.request.url);    const role = url.searchParams.get("role");    return role === "viewer" || role === "guest";  }}
```

TypeScript

```
export class MyAgent extends Agent<Env, State> {  shouldConnectionBeReadonly(    connection: Connection,    ctx: ConnectionContext,  ): boolean {    const url = new URL(ctx.request.url);    const role = url.searchParams.get("role");    return role === "viewer" || role === "guest";  }}
```

This hook runs before the initial state is sent to the client, so the connection is readonly from the very first message.

### At any time

Use `setConnectionReadonly` to change a connection's readonly status dynamically:

* [  JavaScript ](#tab-panel-6127)
* [  TypeScript ](#tab-panel-6128)

JavaScript

```
export class GameAgent extends Agent {  @callable()  async startSpectating() {    const { connection } = getCurrentAgent();    if (connection) {      this.setConnectionReadonly(connection, true);    }  }
  @callable()  async joinAsPlayer() {    const { connection } = getCurrentAgent();    if (connection) {      this.setConnectionReadonly(connection, false);    }  }}
```

TypeScript

```
export class GameAgent extends Agent<Env, GameState> {  @callable()  async startSpectating() {    const { connection } = getCurrentAgent();    if (connection) {      this.setConnectionReadonly(connection, true);    }  }
  @callable()  async joinAsPlayer() {    const { connection } = getCurrentAgent();    if (connection) {      this.setConnectionReadonly(connection, false);    }  }}
```

### Letting a connection toggle its own status

A connection can toggle its own readonly status via a callable. This is useful for lock/unlock UIs where viewers can opt into editing mode:

* [  JavaScript ](#tab-panel-6123)
* [  TypeScript ](#tab-panel-6124)

JavaScript

```
import { Agent, callable, getCurrentAgent } from "agents";
export class CollabAgent extends Agent {  @callable()  async setMyReadonly(readonly) {    const { connection } = getCurrentAgent();    if (connection) {      this.setConnectionReadonly(connection, readonly);    }  }}
```

TypeScript

```
import { Agent, callable, getCurrentAgent } from "agents";
export class CollabAgent extends Agent<Env, State> {  @callable()  async setMyReadonly(readonly: boolean) {    const { connection } = getCurrentAgent();    if (connection) {      this.setConnectionReadonly(connection, readonly);    }  }}
```

On the client:

* [  JavaScript ](#tab-panel-6119)
* [  TypeScript ](#tab-panel-6120)

JavaScript

```
// Toggle between readonly and writableawait agent.call("setMyReadonly", [true]); // lockawait agent.call("setMyReadonly", [false]); // unlock
```

TypeScript

```
// Toggle between readonly and writableawait agent.call("setMyReadonly", [true]); // lockawait agent.call("setMyReadonly", [false]); // unlock
```

### Checking status

Use `isConnectionReadonly` to check a connection's current status:

* [  JavaScript ](#tab-panel-6125)
* [  TypeScript ](#tab-panel-6126)

JavaScript

```
export class MyAgent extends Agent {  @callable()  async getPermissions() {    const { connection } = getCurrentAgent();    if (connection) {      return { canEdit: !this.isConnectionReadonly(connection) };    }  }}
```

TypeScript

```
export class MyAgent extends Agent<Env, State> {  @callable()  async getPermissions() {    const { connection } = getCurrentAgent();    if (connection) {      return { canEdit: !this.isConnectionReadonly(connection) };    }  }}
```

## Handling errors on the client

Errors surface in two ways depending on how the write was attempted:

* **Client-side `setState()`** — the server sends a `cf_agent_state_error` message. Handle it with the `onStateUpdateError` callback.
* **`@callable()` methods** — the RPC call rejects with an error. Handle it with a `try`/`catch` around `agent.call()`.

Note

`onStateUpdateError` also fires when `validateStateChange` rejects a client-originated state update (with the message `"State update rejected"`). This makes the callback useful for handling any rejected state write, not just readonly errors.

* [  JavaScript ](#tab-panel-6129)
* [  TypeScript ](#tab-panel-6130)

JavaScript

```
const agent = useAgent({  agent: "MyAgent",  name: "instance",  // Fires when client-side setState() is blocked  onStateUpdateError: (error) => {    setError(error);  },});
// Fires when a callable that writes state is blockedtry {  await agent.call("updateSettings", [newSettings]);} catch (e) {  setError(e instanceof Error ? e.message : String(e)); // "Connection is readonly"}
```

TypeScript

```
const agent = useAgent({  agent: "MyAgent",  name: "instance",  // Fires when client-side setState() is blocked  onStateUpdateError: (error) => {    setError(error);  },});
// Fires when a callable that writes state is blockedtry {  await agent.call("updateSettings", [newSettings]);} catch (e) {  setError(e instanceof Error ? e.message : String(e)); // "Connection is readonly"}
```

To avoid showing errors in the first place, check permissions before rendering edit controls:

```
function Editor() {  const [canEdit, setCanEdit] = useState(false);  const agent = useAgent({ agent: "MyAgent", name: "instance" });
  useEffect(() => {    agent.call("getPermissions").then((p) => setCanEdit(p.canEdit));  }, []);
  return <button disabled={!canEdit}>{canEdit ? "Edit" : "View Only"}</button>;}
```

## API reference

### `shouldConnectionBeReadonly`

An overridable hook that determines if a connection should be marked as readonly when it connects.

| Parameter   | Type              | Description                  |
| ----------- | ----------------- | ---------------------------- |
| connection  | Connection        | The connecting client        |
| ctx         | ConnectionContext | Contains the upgrade request |
| **Returns** | boolean           | true to mark as readonly     |

Default: returns `false` (all connections are writable).

### `setConnectionReadonly`

Mark or unmark a connection as readonly. Can be called at any time.

| Parameter  | Type       | Description                           |
| ---------- | ---------- | ------------------------------------- |
| connection | Connection | The connection to update              |
| readonly   | boolean    | true to make readonly (default: true) |

### `isConnectionReadonly`

Check if a connection is currently readonly.

| Parameter   | Type       | Description             |
| ----------- | ---------- | ----------------------- |
| connection  | Connection | The connection to check |
| **Returns** | boolean    | true if readonly        |

### `onStateUpdateError` (client)

Callback on `AgentClient` and `useAgent` options. Called when the server rejects a state update.

| Parameter | Type   | Description                   |
| --------- | ------ | ----------------------------- |
| error     | string | Error message from the server |

## Examples

### Query parameter based access

* [  JavaScript ](#tab-panel-6133)
* [  TypeScript ](#tab-panel-6134)

JavaScript

```
export class DocumentAgent extends Agent {  shouldConnectionBeReadonly(connection, ctx) {    const url = new URL(ctx.request.url);    const mode = url.searchParams.get("mode");    return mode === "view";  }}
// Client connects with readonly modeconst agent = useAgent({  agent: "DocumentAgent",  name: "doc-123",  query: { mode: "view" },  onStateUpdateError: (error) => {    toast.error("Document is in view-only mode");  },});
```

TypeScript

```
export class DocumentAgent extends Agent<Env, DocumentState> {  shouldConnectionBeReadonly(    connection: Connection,    ctx: ConnectionContext,  ): boolean {    const url = new URL(ctx.request.url);    const mode = url.searchParams.get("mode");    return mode === "view";  }}
// Client connects with readonly modeconst agent = useAgent({  agent: "DocumentAgent",  name: "doc-123",  query: { mode: "view" },  onStateUpdateError: (error) => {    toast.error("Document is in view-only mode");  },});
```

### Role-based access control

* [  JavaScript ](#tab-panel-6143)
* [  TypeScript ](#tab-panel-6144)

JavaScript

```
export class CollaborativeAgent extends Agent {  shouldConnectionBeReadonly(connection, ctx) {    const url = new URL(ctx.request.url);    const role = url.searchParams.get("role");    return role === "viewer" || role === "guest";  }
  onConnect(connection, ctx) {    const url = new URL(ctx.request.url);    const userId = url.searchParams.get("userId");
    console.log(      `User ${userId} connected (readonly: ${this.isConnectionReadonly(connection)})`,    );  }
  @callable()  async upgradeToEditor() {    const { connection } = getCurrentAgent();    if (!connection) return;
    // Check permissions (pseudo-code)    const canUpgrade = await checkUserPermissions();    if (canUpgrade) {      this.setConnectionReadonly(connection, false);      return { success: true };    }
    throw new Error("Insufficient permissions");  }}
```

TypeScript

```
export class CollaborativeAgent extends Agent<Env, CollabState> {  shouldConnectionBeReadonly(    connection: Connection,    ctx: ConnectionContext,  ): boolean {    const url = new URL(ctx.request.url);    const role = url.searchParams.get("role");    return role === "viewer" || role === "guest";  }
  onConnect(connection: Connection, ctx: ConnectionContext) {    const url = new URL(ctx.request.url);    const userId = url.searchParams.get("userId");
    console.log(      `User ${userId} connected (readonly: ${this.isConnectionReadonly(connection)})`,    );  }
  @callable()  async upgradeToEditor() {    const { connection } = getCurrentAgent();    if (!connection) return;
    // Check permissions (pseudo-code)    const canUpgrade = await checkUserPermissions();    if (canUpgrade) {      this.setConnectionReadonly(connection, false);      return { success: true };    }
    throw new Error("Insufficient permissions");  }}
```

### Admin dashboard

* [  JavaScript ](#tab-panel-6145)
* [  TypeScript ](#tab-panel-6146)

JavaScript

```
export class MonitoringAgent extends Agent {  shouldConnectionBeReadonly(connection, ctx) {    const url = new URL(ctx.request.url);    // Only admins can modify state    return url.searchParams.get("admin") !== "true";  }
  onStateChanged(state, source) {    if (source !== "server") {      // Log who modified the state      console.log(`State modified by connection ${source.id}`);    }  }}
// Admin client (can modify)const adminAgent = useAgent({  agent: "MonitoringAgent",  name: "system",  query: { admin: "true" },});
// Viewer client (readonly)const viewerAgent = useAgent({  agent: "MonitoringAgent",  name: "system",  query: { admin: "false" },  onStateUpdateError: (error) => {    console.log("Viewer cannot modify state");  },});
```

TypeScript

```
export class MonitoringAgent extends Agent<Env, SystemState> {  shouldConnectionBeReadonly(    connection: Connection,    ctx: ConnectionContext,  ): boolean {    const url = new URL(ctx.request.url);    // Only admins can modify state    return url.searchParams.get("admin") !== "true";  }
  onStateChanged(state: SystemState, source: Connection | "server") {    if (source !== "server") {      // Log who modified the state      console.log(`State modified by connection ${source.id}`);    }  }}
// Admin client (can modify)const adminAgent = useAgent({  agent: "MonitoringAgent",  name: "system",  query: { admin: "true" },});
// Viewer client (readonly)const viewerAgent = useAgent({  agent: "MonitoringAgent",  name: "system",  query: { admin: "false" },  onStateUpdateError: (error) => {    console.log("Viewer cannot modify state");  },});
```

### Dynamic permission changes

* [  JavaScript ](#tab-panel-6147)
* [  TypeScript ](#tab-panel-6148)

JavaScript

```
export class GameAgent extends Agent {  @callable()  async startSpectatorMode() {    const { connection } = getCurrentAgent();    if (!connection) return;
    this.setConnectionReadonly(connection, true);    return { mode: "spectator" };  }
  @callable()  async joinAsPlayer() {    const { connection } = getCurrentAgent();    if (!connection) return;
    const canJoin = this.state.players.length < 4;    if (canJoin) {      this.setConnectionReadonly(connection, false);      return { mode: "player" };    }
    throw new Error("Game is full");  }
  @callable()  async getMyPermissions() {    const { connection } = getCurrentAgent();    if (!connection) return null;
    return {      canEdit: !this.isConnectionReadonly(connection),      connectionId: connection.id,    };  }}
```

TypeScript

```
export class GameAgent extends Agent<Env, GameState> {  @callable()  async startSpectatorMode() {    const { connection } = getCurrentAgent();    if (!connection) return;
    this.setConnectionReadonly(connection, true);    return { mode: "spectator" };  }
  @callable()  async joinAsPlayer() {    const { connection } = getCurrentAgent();    if (!connection) return;
    const canJoin = this.state.players.length < 4;    if (canJoin) {      this.setConnectionReadonly(connection, false);      return { mode: "player" };    }
    throw new Error("Game is full");  }
  @callable()  async getMyPermissions() {    const { connection } = getCurrentAgent();    if (!connection) return null;
    return {      canEdit: !this.isConnectionReadonly(connection),      connectionId: connection.id,    };  }}
```

Client-side React component:

```
function GameComponent() {  const [canEdit, setCanEdit] = useState(false);
  const agent = useAgent({    agent: "GameAgent",    name: "game-123",    onStateUpdateError: (error) => {      toast.error("Cannot modify game state in spectator mode");    },  });
  useEffect(() => {    agent.call("getMyPermissions").then((perms) => {      setCanEdit(perms?.canEdit ?? false);    });  }, [agent]);
  return (    <div>      <button onClick={() => agent.call("joinAsPlayer")} disabled={canEdit}>        Join as Player      </button>
      <button        onClick={() => agent.call("startSpectatorMode")}        disabled={!canEdit}      >        Switch to Spectator      </button>
      <div>{canEdit ? "You can modify the game" : "You are spectating"}</div>    </div>  );}
```

## How it works

Readonly status is stored in the connection's WebSocket attachment, which persists through the WebSocket Hibernation API. The flag is namespaced internally so it cannot be accidentally overwritten by `connection.setState()`. The same mechanism is used by [protocol message control](https://developers.cloudflare.com/agents/runtime/communication/protocol-messages/) — both flag coexist safely in the attachment. This means:

* **Survives hibernation** — the flag is serialized and restored when the agent wakes up
* **No cleanup needed** — connection state is automatically discarded when the connection closes
* **Zero overhead** — no database tables or queries, just the connection's built-in attachment
* **Safe from user code** — `connection.state` and `connection.setState()` never expose or overwrite the readonly flag

When a readonly connection tries to modify state, the server blocks it — regardless of whether the write comes from client-side `setState()` or from a `@callable()` method:

```
Client (readonly)                     Agent       │                                │       │  setState({ count: 1 })        │       │ ─────────────────────────────▶ │  Check readonly → blocked       │  ◀───────────────────────────  │       │  cf_agent_state_error          │       │                                │       │  call("increment")             │       │ ─────────────────────────────▶ │  increment() calls this.setState()       │                                │  Check readonly → throw       │  ◀───────────────────────────  │       │  RPC error: "Connection is     │       │              readonly"         │       │                                │       │  call("getPermissions")        │       │ ─────────────────────────────▶ │  getPermissions() — no setState()       │  ◀───────────────────────────  │       │  RPC result: { canEdit: false }│
```

### What readonly does and does not restrict

| Action                                             | Allowed? |
| -------------------------------------------------- | -------- |
| Receive state broadcasts                           | Yes      |
| Call @callable() methods that do not write state   | Yes      |
| Call @callable() methods that call this.setState() | **No**   |
| Send state updates via client-side setState()      | **No**   |

The enforcement happens inside `setState()` itself. When a `@callable()` method tries to call `this.setState()` and the current connection context is readonly, the framework throws an `Error("Connection is readonly")`. This means you do not need manual permission checks in your RPC methods — any callable that writes state is automatically blocked for readonly connections.

## Caveats

### Side effects in callables still run

The readonly check happens inside `this.setState()`, not at the start of the callable. If your method has side effects before the state write, those will still execute:

* [  JavaScript ](#tab-panel-6131)
* [  TypeScript ](#tab-panel-6132)

JavaScript

```
export class MyAgent extends Agent {  @callable()  async processOrder(orderId) {    await sendConfirmationEmail(orderId); // runs even for readonly connections    await chargePayment(orderId); // runs too    this.setState({ ...this.state, orders: [...this.state.orders, orderId] }); // throws  }}
```

TypeScript

```
export class MyAgent extends Agent<Env, State> {  @callable()  async processOrder(orderId: string) {    await sendConfirmationEmail(orderId); // runs even for readonly connections    await chargePayment(orderId); // runs too    this.setState({ ...this.state, orders: [...this.state.orders, orderId] }); // throws  }}
```

To avoid this, either check permissions before side effects or structure your code so the state write comes first:

* [  JavaScript ](#tab-panel-6135)
* [  TypeScript ](#tab-panel-6136)

JavaScript

```
export class MyAgent extends Agent {  @callable()  async processOrder(orderId) {    // Write state first — throws immediately for readonly connections    this.setState({ ...this.state, orders: [...this.state.orders, orderId] });    // Side effects only run if setState succeeded    await sendConfirmationEmail(orderId);    await chargePayment(orderId);  }}
```

TypeScript

```
export class MyAgent extends Agent<Env, State> {  @callable()  async processOrder(orderId: string) {    // Write state first — throws immediately for readonly connections    this.setState({ ...this.state, orders: [...this.state.orders, orderId] });    // Side effects only run if setState succeeded    await sendConfirmationEmail(orderId);    await chargePayment(orderId);  }}
```

## Best practices

### Combine with authentication

* [  JavaScript ](#tab-panel-6139)
* [  TypeScript ](#tab-panel-6140)

JavaScript

```
export class SecureAgent extends Agent {  shouldConnectionBeReadonly(connection, ctx) {    const url = new URL(ctx.request.url);    const token = url.searchParams.get("token");
    // Verify token and get permissions    const permissions = this.verifyToken(token);    return !permissions.canWrite;  }}
```

TypeScript

```
export class SecureAgent extends Agent<Env, State> {  shouldConnectionBeReadonly(    connection: Connection,    ctx: ConnectionContext,  ): boolean {    const url = new URL(ctx.request.url);    const token = url.searchParams.get("token");
    // Verify token and get permissions    const permissions = this.verifyToken(token);    return !permissions.canWrite;  }}
```

### Provide clear user feedback

* [  JavaScript ](#tab-panel-6137)
* [  TypeScript ](#tab-panel-6138)

JavaScript

```
const agent = useAgent({  agent: "MyAgent",  name: "instance",  onStateUpdateError: (error) => {    // User-friendly messages    if (error.includes("readonly")) {      showToast("You are in view-only mode. Upgrade to edit.");    }  },});
```

TypeScript

```
const agent = useAgent({  agent: "MyAgent",  name: "instance",  onStateUpdateError: (error) => {    // User-friendly messages    if (error.includes("readonly")) {      showToast("You are in view-only mode. Upgrade to edit.");    }  },});
```

### Check permissions before UI actions

```
function EditButton() {  const [canEdit, setCanEdit] = useState(false);  const agent = useAgent({    /* ... */  });
  useEffect(() => {    agent.call("checkPermissions").then((perms) => {      setCanEdit(perms.canEdit);    });  }, []);
  return <button disabled={!canEdit}>{canEdit ? "Edit" : "View Only"}</button>;}
```

### Log access attempts

* [  JavaScript ](#tab-panel-6141)
* [  TypeScript ](#tab-panel-6142)

JavaScript

```
export class AuditedAgent extends Agent {  onStateChanged(state, source) {    if (source !== "server") {      this.audit({        action: "state_update",        connectionId: source.id,        readonly: this.isConnectionReadonly(source),        timestamp: Date.now(),      });    }  }}
```

TypeScript

```
export class AuditedAgent extends Agent<Env, State> {  onStateChanged(state: State, source: Connection | "server") {    if (source !== "server") {      this.audit({        action: "state_update",        connectionId: source.id,        readonly: this.isConnectionReadonly(source),        timestamp: Date.now(),      });    }  }}
```

## Limitations

* Readonly status only applies to state updates using `setState()`
* RPC methods can still be called (implement your own checks if needed)
* Readonly is a per-connection flag, not tied to user identity

## Related resources

* [Store and sync state](https://developers.cloudflare.com/agents/runtime/lifecycle/state/)
* [Protocol messages](https://developers.cloudflare.com/agents/runtime/communication/protocol-messages/) — suppress JSON protocol frames for binary-only clients (can be combined with readonly)
* [WebSockets](https://developers.cloudflare.com/agents/runtime/communication/websockets/)
* [Callable methods](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/)

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/communication/readonly-connections/#page","headline":"Readonly connections · Cloudflare Agents docs","description":"Restrict WebSocket clients to view-only access so they receive state updates without modifying Agent state.","url":"https://developers.cloudflare.com/agents/runtime/communication/readonly-connections/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/communication/","name":"Communication"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/communication/readonly-connections/","name":"Readonly connections"}}]}
```

---

---
title: Routing
description: Route HTTP and WebSocket requests to Agents SDK instances using routeAgentRequest() and getAgentByName().
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Routing

This guide explains how requests are routed to agents, how naming works, and patterns for organizing your agents.

## How routing works

When a request comes in, `routeAgentRequest()` examines the URL and routes it to the appropriate agent instance:

```
https://your-worker.dev/agents/{agent-name}/{instance-name}                               └────┬────┘   └─────┬─────┘                               Class name     Unique instance ID                              (kebab-case)
```

**Example URLs:**

| URL                      | Agent Class | Instance |
| ------------------------ | ----------- | -------- |
| /agents/counter/user-123 | Counter     | user-123 |
| /agents/chat-room/lobby  | ChatRoom    | lobby    |
| /agents/my-agent/default | MyAgent     | default  |

## Name resolution

Agent class names are automatically converted to kebab-case for URLs:

| Class Name  | URL Path                 |
| ----------- | ------------------------ |
| Counter     | /agents/counter/...      |
| MyAgent     | /agents/my-agent/...     |
| ChatRoom    | /agents/chat-room/...    |
| AIAssistant | /agents/ai-assistant/... |

The router matches both the original name and kebab-case version, so you can use either:

* `useAgent({ agent: "Counter" })` → `/agents/counter/...`
* `useAgent({ agent: "counter" })` → `/agents/counter/...`

## Using routeAgentRequest()

The `routeAgentRequest()` function is the main entry point for agent routing:

* [  JavaScript ](#tab-panel-6157)
* [  TypeScript ](#tab-panel-6158)

JavaScript

```
import { routeAgentRequest } from "agents";
export default {  async fetch(request, env, ctx) {    // Route to agents - returns Response or undefined    const agentResponse = await routeAgentRequest(request, env);
    if (agentResponse) {      return agentResponse;    }
    // No agent matched - handle other routes    return new Response("Not found", { status: 404 });  },};
```

TypeScript

```
import { routeAgentRequest } from "agents";
export default {  async fetch(request: Request, env: Env, ctx: ExecutionContext) {    // Route to agents - returns Response or undefined    const agentResponse = await routeAgentRequest(request, env);
    if (agentResponse) {      return agentResponse;    }
    // No agent matched - handle other routes    return new Response("Not found", { status: 404 });  },} satisfies ExportedHandler<Env>;
```

## Instance naming patterns

The instance name (the last part of the URL) determines which agent instance handles the request. Each unique name gets its own isolated agent with its own state.

### Per-user agents

Each user gets their own agent instance:

* [  JavaScript ](#tab-panel-6151)
* [  TypeScript ](#tab-panel-6152)

JavaScript

```
// Clientconst agent = useAgent({  agent: "UserProfile",  name: `user-${userId}`, // e.g., "user-abc123"});
```

TypeScript

```
// Clientconst agent = useAgent({  agent: "UserProfile",  name: `user-${userId}`, // e.g., "user-abc123"});
```

```
/agents/user-profile/user-abc123 → User abc123's agent/agents/user-profile/user-xyz789 → User xyz789's agent (separate instance)
```

### Shared rooms

Multiple users share the same agent instance:

* [  JavaScript ](#tab-panel-6153)
* [  TypeScript ](#tab-panel-6154)

JavaScript

```
// Clientconst agent = useAgent({  agent: "ChatRoom",  name: roomId, // e.g., "general" or "room-42"});
```

TypeScript

```
// Clientconst agent = useAgent({  agent: "ChatRoom",  name: roomId, // e.g., "general" or "room-42"});
```

```
/agents/chat-room/general → All users in "general" share this agent
```

### Global singleton

A single instance for the entire application:

* [  JavaScript ](#tab-panel-6155)
* [  TypeScript ](#tab-panel-6156)

JavaScript

```
// Clientconst agent = useAgent({  agent: "AppConfig",  name: "default", // Or any consistent name});
```

TypeScript

```
// Clientconst agent = useAgent({  agent: "AppConfig",  name: "default", // Or any consistent name});
```

### Dynamic naming

Generate instance names based on context:

* [  JavaScript ](#tab-panel-6161)
* [  TypeScript ](#tab-panel-6162)

JavaScript

```
// Per-sessionconst agent = useAgent({  agent: "Session",  name: sessionId,});
// Per-documentconst agent = useAgent({  agent: "Document",  name: `doc-${documentId}`,});
// Per-gameconst agent = useAgent({  agent: "Game",  name: `game-${gameId}-${Date.now()}`,});
```

TypeScript

```
// Per-sessionconst agent = useAgent({  agent: "Session",  name: sessionId,});
// Per-documentconst agent = useAgent({  agent: "Document",  name: `doc-${documentId}`,});
// Per-gameconst agent = useAgent({  agent: "Game",  name: `game-${gameId}-${Date.now()}`,});
```

## Custom URL routing

For advanced use cases where you need control over the URL structure, you can bypass the default `/agents/{agent}/{name}` pattern.

### Using basePath (client-side)

The `basePath` option lets clients connect to any URL path:

* [  JavaScript ](#tab-panel-6159)
* [  TypeScript ](#tab-panel-6160)

JavaScript

```
// Client connects to /user instead of /agents/user-agent/...const agent = useAgent({  agent: "UserAgent", // Required but ignored when basePath is set  basePath: "user", // → connects to /user});
```

TypeScript

```
// Client connects to /user instead of /agents/user-agent/...const agent = useAgent({  agent: "UserAgent", // Required but ignored when basePath is set  basePath: "user", // → connects to /user});
```

This is useful when:

* You want clean URLs without the `/agents/` prefix
* The instance name is determined server-side (for example, from auth/session)
* You are integrating with an existing URL structure

### Server-side instance selection

When using `basePath`, the server must handle routing. Use `getAgentByName()` to get the agent instance, then forward the request with `fetch()`:

* [  JavaScript ](#tab-panel-6171)
* [  TypeScript ](#tab-panel-6172)

JavaScript

```
export default {  async fetch(request, env) {    const url = new URL(request.url);
    // Custom routing - server determines instance from session    if (url.pathname.startsWith("/user/")) {      const session = await getSession(request);      const agent = await getAgentByName(env.UserAgent, session.userId);      return agent.fetch(request); // Forward request directly to agent    }
    // Default routing for standard /agents/... paths    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
export default {  async fetch(request: Request, env: Env) {    const url = new URL(request.url);
    // Custom routing - server determines instance from session    if (url.pathname.startsWith("/user/")) {      const session = await getSession(request);      const agent = await getAgentByName(env.UserAgent, session.userId);      return agent.fetch(request); // Forward request directly to agent    }
    // Default routing for standard /agents/... paths    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

### Custom path with dynamic instance

Route different paths to different instances:

* [  JavaScript ](#tab-panel-6165)
* [  TypeScript ](#tab-panel-6166)

JavaScript

```
// Route /chat/{room} to ChatRoom agentif (url.pathname.startsWith("/chat/")) {  const roomId = url.pathname.replace("/chat/", "");  const agent = await getAgentByName(env.ChatRoom, roomId);  return agent.fetch(request);}
// Route /doc/{id} to Document agentif (url.pathname.startsWith("/doc/")) {  const docId = url.pathname.replace("/doc/", "");  const agent = await getAgentByName(env.Document, docId);  return agent.fetch(request);}
```

TypeScript

```
// Route /chat/{room} to ChatRoom agentif (url.pathname.startsWith("/chat/")) {  const roomId = url.pathname.replace("/chat/", "");  const agent = await getAgentByName(env.ChatRoom, roomId);  return agent.fetch(request);}
// Route /doc/{id} to Document agentif (url.pathname.startsWith("/doc/")) {  const docId = url.pathname.replace("/doc/", "");  const agent = await getAgentByName(env.Document, docId);  return agent.fetch(request);}
```

### Receiving the instance identity (client-side)

When using `basePath`, the client does not know which instance it connected to until the server returns this information. The agent automatically sends its identity on connection:

* [  JavaScript ](#tab-panel-6173)
* [  TypeScript ](#tab-panel-6174)

JavaScript

```
const agent = useAgent({  agent: "UserAgent",  basePath: "user",  onIdentity: (name, agentType) => {    console.log(`Connected to ${agentType} instance: ${name}`);    // e.g., "Connected to user-agent instance: user-123"  },});
// Reactive state - re-renders when identity is receivedreturn (  <div>    {agent.identified ? `Connected to: ${agent.name}` : "Connecting..."}  </div>);
```

TypeScript

```
const agent = useAgent({  agent: "UserAgent",  basePath: "user",  onIdentity: (name, agentType) => {    console.log(`Connected to ${agentType} instance: ${name}`);    // e.g., "Connected to user-agent instance: user-123"  },});
// Reactive state - re-renders when identity is receivedreturn (  <div>    {agent.identified ? `Connected to: ${agent.name}` : "Connecting..."}  </div>);
```

For `AgentClient`:

* [  JavaScript ](#tab-panel-6175)
* [  TypeScript ](#tab-panel-6176)

JavaScript

```
const agent = new AgentClient({  agent: "UserAgent",  basePath: "user",  host: "example.com",  onIdentity: (name, agentType) => {    // Update UI with actual instance name    setInstanceName(name);  },});
// Wait for identity before proceedingawait agent.ready;console.log(agent.name); // Now has the server-determined name
```

TypeScript

```
const agent = new AgentClient({  agent: "UserAgent",  basePath: "user",  host: "example.com",  onIdentity: (name, agentType) => {    // Update UI with actual instance name    setInstanceName(name);  },});
// Wait for identity before proceedingawait agent.ready;console.log(agent.name); // Now has the server-determined name
```

### Handling identity changes on reconnect

If the identity changes on reconnect (for example, session expired and user logs in as someone else), you can handle it with `onIdentityChange`:

* [  JavaScript ](#tab-panel-6169)
* [  TypeScript ](#tab-panel-6170)

JavaScript

```
const agent = useAgent({  agent: "UserAgent",  basePath: "user",  onIdentityChange: (oldName, newName, oldAgent, newAgent) => {    console.log(`Session changed: ${oldName} → ${newName}`);    // Refresh state, show notification, etc.  },});
```

TypeScript

```
const agent = useAgent({  agent: "UserAgent",  basePath: "user",  onIdentityChange: (oldName, newName, oldAgent, newAgent) => {    console.log(`Session changed: ${oldName} → ${newName}`);    // Refresh state, show notification, etc.  },});
```

If `onIdentityChange` is not provided and identity changes, a warning is logged to help catch unexpected session changes.

### Disabling identity for security

If your instance names contain sensitive data (session IDs, internal user IDs), you can disable identity sending:

* [  JavaScript ](#tab-panel-6163)
* [  TypeScript ](#tab-panel-6164)

JavaScript

```
class SecureAgent extends Agent {  // Do not expose instance names to clients  static options = { sendIdentityOnConnect: false };}
```

TypeScript

```
class SecureAgent extends Agent {  // Do not expose instance names to clients  static options = { sendIdentityOnConnect: false };}
```

When identity is disabled:

* `agent.identified` stays `false`
* `agent.ready` never resolves (use state updates instead)
* `onIdentity` and `onIdentityChange` are never called

### When to use custom routing

| Scenario                        | Approach                            |
| ------------------------------- | ----------------------------------- |
| Standard agent access           | Default /agents/{agent}/{name}      |
| Instance from auth/session      | basePath \+ getAgentByName \+ fetch |
| Clean URLs (no /agents/ prefix) | basePath \+ custom routing          |
| Legacy URL structure            | basePath \+ custom routing          |
| Complex routing logic           | Custom routing in Worker            |

## Routing options

Both `routeAgentRequest()` and `getAgentByName()` accept options for customizing routing behavior.

### CORS

For cross-origin requests (common when your frontend is on a different domain):

* [  JavaScript ](#tab-panel-6167)
* [  TypeScript ](#tab-panel-6168)

JavaScript

```
const response = await routeAgentRequest(request, env, {  cors: true, // Enable default CORS headers});
```

TypeScript

```
const response = await routeAgentRequest(request, env, {  cors: true, // Enable default CORS headers});
```

Or with custom CORS headers:

* [  JavaScript ](#tab-panel-6177)
* [  TypeScript ](#tab-panel-6178)

JavaScript

```
const response = await routeAgentRequest(request, env, {  cors: {    "Access-Control-Allow-Origin": "https://myapp.com",    "Access-Control-Allow-Methods": "GET, POST, OPTIONS",    "Access-Control-Allow-Headers": "Content-Type, Authorization",  },});
```

TypeScript

```
const response = await routeAgentRequest(request, env, {  cors: {    "Access-Control-Allow-Origin": "https://myapp.com",    "Access-Control-Allow-Methods": "GET, POST, OPTIONS",    "Access-Control-Allow-Headers": "Content-Type, Authorization",  },});
```

### Location hints

For latency-sensitive applications, hint where the agent should run:

* [  JavaScript ](#tab-panel-6179)
* [  TypeScript ](#tab-panel-6180)

JavaScript

```
// With getAgentByNameconst agent = await getAgentByName(env.MyAgent, "instance-name", {  locationHint: "enam", // Eastern North America});
// With routeAgentRequest (applies to all matched agents)const response = await routeAgentRequest(request, env, {  locationHint: "enam",});
```

TypeScript

```
// With getAgentByNameconst agent = await getAgentByName(env.MyAgent, "instance-name", {  locationHint: "enam", // Eastern North America});
// With routeAgentRequest (applies to all matched agents)const response = await routeAgentRequest(request, env, {  locationHint: "enam",});
```

Available location hints: `wnam`, `enam`, `sam`, `weur`, `eeur`, `apac`, `oc`, `afr`, `me`

### Jurisdiction

For data residency requirements:

* [  JavaScript ](#tab-panel-6183)
* [  TypeScript ](#tab-panel-6184)

JavaScript

```
// With getAgentByNameconst agent = await getAgentByName(env.MyAgent, "instance-name", {  jurisdiction: "eu", // EU jurisdiction});
// With routeAgentRequest (applies to all matched agents)const response = await routeAgentRequest(request, env, {  jurisdiction: "eu",});
```

TypeScript

```
// With getAgentByNameconst agent = await getAgentByName(env.MyAgent, "instance-name", {  jurisdiction: "eu", // EU jurisdiction});
// With routeAgentRequest (applies to all matched agents)const response = await routeAgentRequest(request, env, {  jurisdiction: "eu",});
```

### Props

Since agents are instantiated by the runtime rather than constructed directly, `props` provides a way to pass initialization arguments:

* [  JavaScript ](#tab-panel-6181)
* [  TypeScript ](#tab-panel-6182)

JavaScript

```
const agent = await getAgentByName(env.MyAgent, "instance-name", {  props: {    userId: session.userId,    config: { maxRetries: 3 },  },});
```

TypeScript

```
const agent = await getAgentByName(env.MyAgent, "instance-name", {  props: {    userId: session.userId,    config: { maxRetries: 3 },  },});
```

Props are passed to the agent's `onStart` lifecycle method:

* [  JavaScript ](#tab-panel-6185)
* [  TypeScript ](#tab-panel-6186)

JavaScript

```
class MyAgent extends Agent {  userId;  config;
  async onStart(props) {    this.userId = props?.userId;    this.config = props?.config;  }}
```

TypeScript

```
class MyAgent extends Agent<Env, State> {  private userId?: string;  private config?: { maxRetries: number };
  async onStart(props?: { userId: string; config: { maxRetries: number } }) {    this.userId = props?.userId;    this.config = props?.config;  }}
```

When using `props` with `routeAgentRequest`, the same props are passed to whichever agent matches the URL. This works well for universal context like authentication:

* [  JavaScript ](#tab-panel-6189)
* [  TypeScript ](#tab-panel-6190)

JavaScript

```
export default {  async fetch(request, env) {    const session = await getSession(request);    return routeAgentRequest(request, env, {      props: { userId: session.userId, role: session.role },    });  },};
```

TypeScript

```
export default {  async fetch(request, env) {    const session = await getSession(request);    return routeAgentRequest(request, env, {      props: { userId: session.userId, role: session.role },    });  },} satisfies ExportedHandler<Env>;
```

For agent-specific initialization, use `getAgentByName` instead where you control exactly which agent receives the props.

Note

For `McpAgent`, props are automatically stored and accessible via `this.props`. Refer to [MCP servers](https://developers.cloudflare.com/agents/model-context-protocol/apis/agent-api/) for details.

### Routing retry

Use `routingRetry` with `getAgentByName()` when server-side code should retry transient Durable Object routing failures:

* [  JavaScript ](#tab-panel-6187)
* [  TypeScript ](#tab-panel-6188)

JavaScript

```
const agent = await getAgentByName(env.MyAgent, "instance-name", {  routingRetry: {    maxAttempts: 3,  },});
```

TypeScript

```
const agent = await getAgentByName(env.MyAgent, "instance-name", {  routingRetry: {    maxAttempts: 3,  },});
```

This option is useful for request forwarding and RPC paths where a short-lived routing failure should be retried before returning an error to the caller.

### Hooks

`routeAgentRequest` supports hooks for intercepting requests before they reach agents:

* [  JavaScript ](#tab-panel-6191)
* [  TypeScript ](#tab-panel-6192)

JavaScript

```
const response = await routeAgentRequest(request, env, {  onBeforeConnect: (req, lobby) => {    // Called before WebSocket connections    // Return a Response to reject, Request to modify, or void to continue  },  onBeforeRequest: (req, lobby) => {    // Called before HTTP requests    // Return a Response to reject, Request to modify, or void to continue  },});
```

TypeScript

```
const response = await routeAgentRequest(request, env, {  onBeforeConnect: (req, lobby) => {    // Called before WebSocket connections    // Return a Response to reject, Request to modify, or void to continue  },  onBeforeRequest: (req, lobby) => {    // Called before HTTP requests    // Return a Response to reject, Request to modify, or void to continue  },});
```

These hooks are useful for authentication and validation. Refer to [Cross-domain authentication](https://developers.cloudflare.com/agents/runtime/operations/cross-domain-authentication/) for detailed examples.

## Server-side agent access

You can access agents from your Worker code using `getAgentByName()` for RPC calls:

* [  JavaScript ](#tab-panel-6197)
* [  TypeScript ](#tab-panel-6198)

JavaScript

```
import { getAgentByName, routeAgentRequest } from "agents";
export default {  async fetch(request, env) {    const url = new URL(request.url);
    // API endpoint that interacts with an agent    if (url.pathname === "/api/increment") {      const counter = await getAgentByName(env.Counter, "global-counter");      const newCount = await counter.increment();      return Response.json({ count: newCount });    }
    // Regular agent routing    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
import { getAgentByName, routeAgentRequest } from "agents";
export default {  async fetch(request: Request, env: Env) {    const url = new URL(request.url);
    // API endpoint that interacts with an agent    if (url.pathname === "/api/increment") {      const counter = await getAgentByName(env.Counter, "global-counter");      const newCount = await counter.increment();      return Response.json({ count: newCount });    }
    // Regular agent routing    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

For options like `locationHint`, `jurisdiction`, and `props`, refer to [Routing options](#routing-options).

## Sub-paths and HTTP methods

Requests can include sub-paths after the instance name. These are passed to your agent's `onRequest()` handler:

```
/agents/api/v1/users     → agent: "api", instance: "v1", path: "/users"/agents/api/v1/users/123 → agent: "api", instance: "v1", path: "/users/123"
```

Handle sub-paths in your agent:

* [  JavaScript ](#tab-panel-6199)
* [  TypeScript ](#tab-panel-6200)

JavaScript

```
export class API extends Agent {  async onRequest(request) {    const url = new URL(request.url);
    // url.pathname contains the full path including /agents/api/v1/...    // Extract the sub-path after your agent's base path    const path = url.pathname.replace(/^\/agents\/api\/[^/]+/, "");
    if (request.method === "GET" && path === "/users") {      return Response.json(await this.getUsers());    }
    if (request.method === "POST" && path === "/users") {      const data = await request.json();      return Response.json(await this.createUser(data));    }
    return new Response("Not found", { status: 404 });  }}
```

TypeScript

```
export class API extends Agent {  async onRequest(request: Request): Promise<Response> {    const url = new URL(request.url);
    // url.pathname contains the full path including /agents/api/v1/...    // Extract the sub-path after your agent's base path    const path = url.pathname.replace(/^\/agents\/api\/[^/]+/, "");
    if (request.method === "GET" && path === "/users") {      return Response.json(await this.getUsers());    }
    if (request.method === "POST" && path === "/users") {      const data = await request.json();      return Response.json(await this.createUser(data));    }
    return new Response("Not found", { status: 404 });  }}
```

## Multiple agents

You can have multiple agent classes in one project. Each gets its own namespace:

* [  JavaScript ](#tab-panel-6195)
* [  TypeScript ](#tab-panel-6196)

JavaScript

```
// server.tsexport { Counter } from "./agents/counter";export { ChatRoom } from "./agents/chat-room";export { UserProfile } from "./agents/user-profile";
export default {  async fetch(request, env) {    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
// server.tsexport { Counter } from "./agents/counter";export { ChatRoom } from "./agents/chat-room";export { UserProfile } from "./agents/user-profile";
export default {  async fetch(request: Request, env: Env) {    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

* [  wrangler.jsonc ](#tab-panel-6149)
* [  wrangler.toml ](#tab-panel-6150)

JSONC

```
{  "durable_objects": {    "bindings": [      { "name": "Counter", "class_name": "Counter" },      { "name": "ChatRoom", "class_name": "ChatRoom" },      { "name": "UserProfile", "class_name": "UserProfile" },    ],  },  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": ["Counter", "ChatRoom", "UserProfile"],    },  ],}
```

TOML

```
[[durable_objects.bindings]]name = "Counter"class_name = "Counter"
[[durable_objects.bindings]]name = "ChatRoom"class_name = "ChatRoom"
[[durable_objects.bindings]]name = "UserProfile"class_name = "UserProfile"
[[migrations]]tag = "v1"new_sqlite_classes = [ "Counter", "ChatRoom", "UserProfile" ]
```

Each agent is accessed via its own path:

```
/agents/counter/.../agents/chat-room/.../agents/user-profile/...
```

## Request flow

Here is how a request flows through the system:

flowchart TD
    A["HTTP Request<br/>or WebSocket"] --> B["routeAgentRequest<br/>Parse URL path"]
    B --> C["Find binding in<br/>env by name"]
    C --> D["Get/create DO<br/>by instance ID"]
    D --> E["Agent Instance"]
    E --> F{"Protocol?"}
    F -->|WebSocket| G["onConnect(), onMessage"]
    F -->|HTTP| H["onRequest()"]

## Routing with authentication

There are several ways to authenticate requests before they reach your agent.

### Using authentication hooks

The `routeAgentRequest()` function provides `onBeforeConnect` and `onBeforeRequest` hooks for authentication:

* [  JavaScript ](#tab-panel-6205)
* [  TypeScript ](#tab-panel-6206)

JavaScript

```
import { Agent, routeAgentRequest } from "agents";
export default {  async fetch(request, env) {    return (      (await routeAgentRequest(request, env, {        // Run before WebSocket connections        onBeforeConnect: async (request) => {          const token = new URL(request.url).searchParams.get("token");          if (!(await verifyToken(token, env))) {            // Return a response to reject the connection            return new Response("Unauthorized", { status: 401 });          }          // Return nothing to allow the connection        },        // Run before HTTP requests        onBeforeRequest: async (request) => {          const auth = request.headers.get("Authorization");          if (!auth || !(await verifyAuth(auth, env))) {            return new Response("Unauthorized", { status: 401 });          }        },        // Optional: prepend a prefix to agent instance names        prefix: "user-",      })) ?? new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
import { Agent, routeAgentRequest } from "agents";
export default {  async fetch(request: Request, env: Env) {    return (      (await routeAgentRequest(request, env, {        // Run before WebSocket connections        onBeforeConnect: async (request) => {          const token = new URL(request.url).searchParams.get("token");          if (!(await verifyToken(token, env))) {            // Return a response to reject the connection            return new Response("Unauthorized", { status: 401 });          }          // Return nothing to allow the connection        },        // Run before HTTP requests        onBeforeRequest: async (request) => {          const auth = request.headers.get("Authorization");          if (!auth || !(await verifyAuth(auth, env))) {            return new Response("Unauthorized", { status: 401 });          }        },        // Optional: prepend a prefix to agent instance names        prefix: "user-",      })) ?? new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

### Manual authentication

Check authentication before calling `routeAgentRequest()`:

* [  JavaScript ](#tab-panel-6201)
* [  TypeScript ](#tab-panel-6202)

JavaScript

```
export default {  async fetch(request, env) {    const url = new URL(request.url);
    // Protect agent routes    if (url.pathname.startsWith("/agents/")) {      const user = await authenticate(request, env);      if (!user) {        return new Response("Unauthorized", { status: 401 });      }
      // Optionally, enforce that users can only access their own agents      const instanceName = url.pathname.split("/")[3];      if (instanceName !== `user-${user.id}`) {        return new Response("Forbidden", { status: 403 });      }    }
    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },};
```

TypeScript

```
export default {  async fetch(request: Request, env: Env) {    const url = new URL(request.url);
    // Protect agent routes    if (url.pathname.startsWith("/agents/")) {      const user = await authenticate(request, env);      if (!user) {        return new Response("Unauthorized", { status: 401 });      }
      // Optionally, enforce that users can only access their own agents      const instanceName = url.pathname.split("/")[3];      if (instanceName !== `user-${user.id}`) {        return new Response("Forbidden", { status: 403 });      }    }
    return (      (await routeAgentRequest(request, env)) ??      new Response("Not found", { status: 404 })    );  },} satisfies ExportedHandler<Env>;
```

### Using a framework (Hono)

If you are using a framework like [Hono ↗](https://hono.dev/), authenticate in middleware before calling the agent:

* [  JavaScript ](#tab-panel-6203)
* [  TypeScript ](#tab-panel-6204)

JavaScript

```
import { Agent, getAgentByName } from "agents";import { Hono } from "hono";
const app = new Hono();
// Authentication middlewareapp.use("/agents/*", async (c, next) => {  const token = c.req.header("Authorization")?.replace("Bearer ", "");  if (!token || !(await verifyToken(token, c.env))) {    return c.json({ error: "Unauthorized" }, 401);  }  await next();});
// Route to a specific agentapp.all("/agents/code-review/:id/*", async (c) => {  const id = c.req.param("id");  const agent = await getAgentByName(c.env.CodeReviewAgent, id);  return agent.fetch(c.req.raw);});
export default app;
```

TypeScript

```
import { Agent, getAgentByName } from "agents";import { Hono } from "hono";
const app = new Hono<{ Bindings: Env }>();
// Authentication middlewareapp.use("/agents/*", async (c, next) => {  const token = c.req.header("Authorization")?.replace("Bearer ", "");  if (!token || !(await verifyToken(token, c.env))) {    return c.json({ error: "Unauthorized" }, 401);  }  await next();});
// Route to a specific agentapp.all("/agents/code-review/:id/*", async (c) => {  const id = c.req.param("id");  const agent = await getAgentByName(c.env.CodeReviewAgent, id);  return agent.fetch(c.req.raw);});
export default app;
```

For WebSocket authentication patterns (tokens in URLs, JWT refresh), refer to [Cross-domain authentication](https://developers.cloudflare.com/agents/runtime/operations/cross-domain-authentication/).

## Troubleshooting

### Agent namespace not found

The error message lists available agents. Check:

1. Agent class is exported from your entry point.
2. Class name in code matches `class_name` in `wrangler.jsonc`.
3. URL uses correct kebab-case name.

### Request returns 404

1. Verify the URL pattern: `/agents/{agent-name}/{instance-name}`.
2. Check that `routeAgentRequest()` is called before your 404 handler.
3. Ensure the response from `routeAgentRequest()` is returned (not just called).

### WebSocket connection fails

1. Do not modify the response from `routeAgentRequest()` for WebSocket upgrades.
2. Ensure CORS is enabled if connecting from a different origin.
3. Check browser dev tools for the actual error.

### `basePath` not working

1. Ensure your Worker handles the custom path and forwards to the agent.
2. Use `getAgentByName()` \+ `agent.fetch(request)` to forward requests.
3. The `agent` parameter is still required but ignored when `basePath` is set.
4. Check that the server-side route matches the client's `basePath`.

## API reference

### `routeAgentRequest(request, env, options?)`

Routes a request to the appropriate agent.

| Parameter               | Type                    | Description                                     |
| ----------------------- | ----------------------- | ----------------------------------------------- |
| request                 | Request                 | The incoming request                            |
| env                     | Env                     | Environment with agent bindings                 |
| options.cors            | boolean \| HeadersInit  | Enable CORS headers                             |
| options.props           | Record<string, unknown> | Props passed to whichever agent handles request |
| options.locationHint    | string                  | Preferred location for agent instances          |
| options.jurisdiction    | string                  | Data jurisdiction for agent instances           |
| options.onBeforeConnect | Function                | Callback before WebSocket connections           |
| options.onBeforeRequest | Function                | Callback before HTTP requests                   |

**Returns:** `Promise<Response | undefined>` \- Response if matched, undefined if no agent route.

### `getAgentByName(namespace, name, options?)`

Get an agent instance by name for server-side RPC or request forwarding.

| Parameter            | Type                      | Description                                                       |
| -------------------- | ------------------------- | ----------------------------------------------------------------- |
| namespace            | DurableObjectNamespace<T> | Agent binding from env                                            |
| name                 | string                    | Instance name                                                     |
| options.locationHint | string                    | Preferred location                                                |
| options.jurisdiction | string                    | Data jurisdiction                                                 |
| options.props        | Record<string, unknown>   | Initialization properties for onStart                             |
| options.routingRetry | object                    | Retry configuration for transient Durable Object routing failures |

**Returns:** `Promise<DurableObjectStub<T>>` \- Typed stub for calling agent methods or forwarding requests.

### `useAgent(options)` / `AgentClient` options

Client connection options for custom routing:

| Option           | Type                                           | Description                                          |
| ---------------- | ---------------------------------------------- | ---------------------------------------------------- |
| agent            | string                                         | Agent class name (required)                          |
| name             | string                                         | Instance name (default: "default")                   |
| basePath         | string                                         | Full URL path - bypasses agent/name URL construction |
| path             | string                                         | Additional path to append to the URL                 |
| onIdentity       | (name, agent) => void                          | Called when server sends identity                    |
| onIdentityChange | (oldName, newName, oldAgent, newAgent) => void | Called when identity changes on reconnect            |

**Return value properties (React hook):**

| Property   | Type          | Description                                   |
| ---------- | ------------- | --------------------------------------------- |
| name       | string        | Current instance name (reactive)              |
| agent      | string        | Current agent class name (reactive)           |
| identified | boolean       | Whether identity has been received (reactive) |
| ready      | Promise<void> | Resolves when identity is received            |

### `Agent.options` (server)

Static options for agent configuration:

| Option                     | Type    | Default | Description                                          |
| -------------------------- | ------- | ------- | ---------------------------------------------------- |
| hibernate                  | boolean | true    | Whether the agent should hibernate when inactive     |
| sendIdentityOnConnect      | boolean | true    | Whether to send identity to clients on connect       |
| hungScheduleTimeoutSeconds | number  | 30      | Timeout before a running schedule is considered hung |

* [  JavaScript ](#tab-panel-6193)
* [  TypeScript ](#tab-panel-6194)

JavaScript

```
class SecureAgent extends Agent {  static options = { sendIdentityOnConnect: false };}
```

TypeScript

```
class SecureAgent extends Agent {  static options = { sendIdentityOnConnect: false };}
```

## Next steps

[ Client SDK ](https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/) Connect from browsers with useAgent and AgentClient. 

[ Cross-domain authentication ](https://developers.cloudflare.com/agents/runtime/operations/cross-domain-authentication/) WebSocket authentication patterns. 

[ Callable methods ](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/) RPC from clients over WebSocket. 

[ Configuration ](https://developers.cloudflare.com/agents/runtime/operations/configuration/) Set up agent bindings in wrangler.jsonc.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/communication/routing/#page","headline":"Routing · Cloudflare Agents docs","description":"Route HTTP and WebSocket requests to Agents SDK instances using routeAgentRequest() and getAgentByName().","url":"https://developers.cloudflare.com/agents/runtime/communication/routing/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/communication/","name":"Communication"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/communication/routing/","name":"Routing"}}]}
```

---

---
title: WebSockets
description: Handle real-time WebSocket connections, messages, broadcasts, and lifecycle hooks in the Agents SDK.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# WebSockets

Agents support WebSocket connections for real-time, bi-directional communication. This page covers server-side WebSocket handling. For client-side connection, refer to the [Client SDK](https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/).

## Lifecycle hooks

Agents have several lifecycle hooks that fire at different points:

| Hook                                        | When called                                                                                |
| ------------------------------------------- | ------------------------------------------------------------------------------------------ |
| onStart(props?)                             | Once when the agent first starts (before any connections)                                  |
| onRequest(request)                          | When an HTTP request is received (non-WebSocket)                                           |
| onConnect(connection, ctx)                  | When a new WebSocket connection is established                                             |
| onMessage(connection, message)              | When a WebSocket message is received                                                       |
| onClose(connection, code, reason, wasClean) | When a WebSocket connection closes                                                         |
| onError(connection, error)                  | When a WebSocket error occurs on a connection                                              |
| onError(error)                              | When a server-level error occurs (not tied to a specific connection)                       |
| shouldSendProtocolMessages(connection, ctx) | Whether to send protocol messages (identity, state, MCP) to this connection. Default: true |

### `onStart`

`onStart()` is called once when the agent first starts, before any connections are established:

* [  JavaScript ](#tab-panel-6209)
* [  TypeScript ](#tab-panel-6210)

JavaScript

```
export class MyAgent extends Agent {  async onStart() {    // Initialize resources    console.log(`Agent ${this.name} starting...`);
    // Load data from storage    const savedData = this.sql`SELECT * FROM cache`;    for (const row of savedData) {      // Rebuild in-memory state from persistent storage    }  }
  onConnect(connection) {    // By the time connections arrive, onStart has completed  }}
```

TypeScript

```
export class MyAgent extends Agent {  async onStart() {    // Initialize resources    console.log(`Agent ${this.name} starting...`);
    // Load data from storage    const savedData = this.sql`SELECT * FROM cache`;    for (const row of savedData) {      // Rebuild in-memory state from persistent storage    }  }
  onConnect(connection: Connection) {    // By the time connections arrive, onStart has completed  }}
```

## Handling connections

Define `onConnect` and `onMessage` methods on your Agent to accept WebSocket connections:

* [  JavaScript ](#tab-panel-6215)
* [  TypeScript ](#tab-panel-6216)

JavaScript

```
import { Agent, Connection, ConnectionContext, WSMessage } from "agents";
export class ChatAgent extends Agent {  async onConnect(connection, ctx) {    // Connections are automatically accepted    // Access the original request for auth, headers, cookies    const url = new URL(ctx.request.url);    const token = url.searchParams.get("token");
    if (!token) {      connection.close(4001, "Unauthorized");      return;    }
    // Store user info on this connection    connection.setState({ authenticated: true });  }
  async onMessage(connection, message) {    if (typeof message === "string") {      // Handle text message      const data = JSON.parse(message);      connection.send(JSON.stringify({ received: data }));    }  }}
```

TypeScript

```
import { Agent, Connection, ConnectionContext, WSMessage } from "agents";
export class ChatAgent extends Agent {  async onConnect(connection: Connection, ctx: ConnectionContext) {    // Connections are automatically accepted    // Access the original request for auth, headers, cookies    const url = new URL(ctx.request.url);    const token = url.searchParams.get("token");
    if (!token) {      connection.close(4001, "Unauthorized");      return;    }
    // Store user info on this connection    connection.setState({ authenticated: true });  }
  async onMessage(connection: Connection, message: WSMessage) {    if (typeof message === "string") {      // Handle text message      const data = JSON.parse(message);      connection.send(JSON.stringify({ received: data }));    }  }}
```

## Connection object

Each connected client has a unique `Connection` object:

| Property/Method       | Type                | Description                                                                             |
| --------------------- | ------------------- | --------------------------------------------------------------------------------------- |
| id                    | string              | Unique identifier for this connection                                                   |
| uri                   | string \| null      | URL of the original WebSocket upgrade request. Persists across hibernation              |
| state                 | State               | Per-connection state object                                                             |
| setState(state)       | void                | Update connection state                                                                 |
| send(message)         | void                | Send message to this client                                                             |
| close(code?, reason?) | void                | Close the connection                                                                    |
| tags                  | readonly string\[\] | Tags assigned via getConnectionTags. Always includes the connection ID as the first tag |
| server                | string              | The agent instance name (same as this.name on the Agent)                                |

### Per-connection state

Store data specific to each connection (user info, preferences, etc.):

* [  JavaScript ](#tab-panel-6219)
* [  TypeScript ](#tab-panel-6220)

JavaScript

```
export class ChatAgent extends Agent {  async onConnect(connection, ctx) {    const userId = new URL(ctx.request.url).searchParams.get("userId");
    connection.setState({      userId: userId || "anonymous",      role: "user",      joinedAt: Date.now(),    });  }
  async onMessage(connection, message) {    // Access connection-specific state    console.log(`Message from ${connection.state.userId}`);  }}
```

TypeScript

```
interface ConnectionState {  userId: string;  role: "admin" | "user";  joinedAt: number;}
export class ChatAgent extends Agent {  async onConnect(    connection: Connection<ConnectionState>,    ctx: ConnectionContext,  ) {    const userId = new URL(ctx.request.url).searchParams.get("userId");
    connection.setState({      userId: userId || "anonymous",      role: "user",      joinedAt: Date.now(),    });  }
  async onMessage(connection: Connection<ConnectionState>, message: WSMessage) {    // Access connection-specific state    console.log(`Message from ${connection.state.userId}`);  }}
```

## Broadcasting to all clients

Use `this.broadcast()` to send a message to all connected clients:

* [  JavaScript ](#tab-panel-6213)
* [  TypeScript ](#tab-panel-6214)

JavaScript

```
export class ChatAgent extends Agent {  async onMessage(connection, message) {    // Broadcast to all connected clients    this.broadcast(      JSON.stringify({        from: connection.id,        message: message,        timestamp: Date.now(),      }),    );  }
  // Broadcast from any method  async notifyAll(event, data) {    this.broadcast(JSON.stringify({ event, data }));  }}
```

TypeScript

```
export class ChatAgent extends Agent {  async onMessage(connection: Connection, message: WSMessage) {    // Broadcast to all connected clients    this.broadcast(      JSON.stringify({        from: connection.id,        message: message,        timestamp: Date.now(),      }),    );  }
  // Broadcast from any method  async notifyAll(event: string, data: unknown) {    this.broadcast(JSON.stringify({ event, data }));  }}
```

### Excluding connections

Pass an array of connection IDs to exclude from the broadcast:

* [  JavaScript ](#tab-panel-6207)
* [  TypeScript ](#tab-panel-6208)

JavaScript

```
// Broadcast to everyone except the senderthis.broadcast(  JSON.stringify({ type: "user-typing", userId: "123" }),  [connection.id], // Do not send to the originator);
```

TypeScript

```
// Broadcast to everyone except the senderthis.broadcast(  JSON.stringify({ type: "user-typing", userId: "123" }),  [connection.id], // Do not send to the originator);
```

## Connection tags

Tag connections for easy filtering. Override `getConnectionTags()` to assign tags when a connection is established:

* [  JavaScript ](#tab-panel-6221)
* [  TypeScript ](#tab-panel-6222)

JavaScript

```
export class ChatAgent extends Agent {  getConnectionTags(connection, ctx) {    const url = new URL(ctx.request.url);    const role = url.searchParams.get("role");
    const tags = [];    if (role === "admin") tags.push("admin");    if (role === "moderator") tags.push("moderator");
    return tags; // Up to 9 tags, max 256 chars each  }
  // Later, broadcast only to admins  notifyAdmins(message) {    for (const conn of this.getConnections("admin")) {      conn.send(message);    }  }}
```

TypeScript

```
export class ChatAgent extends Agent {  getConnectionTags(connection: Connection, ctx: ConnectionContext): string[] {    const url = new URL(ctx.request.url);    const role = url.searchParams.get("role");
    const tags: string[] = [];    if (role === "admin") tags.push("admin");    if (role === "moderator") tags.push("moderator");
    return tags; // Up to 9 tags, max 256 chars each  }
  // Later, broadcast only to admins  notifyAdmins(message: string) {    for (const conn of this.getConnections("admin")) {      conn.send(message);    }  }}
```

### Connection management methods

| Method                      | Signature                               | Description                                                                                                               |
| --------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| getConnections              | (tag?: string) => Iterable<Connection>  | Get all connections, optionally by tag                                                                                    |
| getConnection               | (id: string) => Connection \| undefined | Get connection by ID                                                                                                      |
| getConnectionTags           | (connection, ctx) => string\[\]         | Override to tag connections                                                                                               |
| broadcast                   | (message, without?: string\[\]) => void | Send to all connections                                                                                                   |
| isConnectionReadonly        | (connection) => boolean                 | Check if a connection is [readonly](https://developers.cloudflare.com/agents/runtime/communication/readonly-connections/) |
| isConnectionProtocolEnabled | (connection) => boolean                 | Check if protocol messages are enabled for this connection                                                                |

## Handling binary data

Messages can be strings or binary (`ArrayBuffer` / `ArrayBufferView`):

* [  JavaScript ](#tab-panel-6217)
* [  TypeScript ](#tab-panel-6218)

JavaScript

```
export class FileAgent extends Agent {  async onMessage(connection, message) {    if (message instanceof ArrayBuffer) {      // Handle binary upload      const bytes = new Uint8Array(message);      await this.processFile(bytes);      connection.send(        JSON.stringify({ status: "received", size: bytes.length }),      );    } else if (typeof message === "string") {      // Handle text command      const command = JSON.parse(message);      // ...    }  }}
```

TypeScript

```
export class FileAgent extends Agent {  async onMessage(connection: Connection, message: WSMessage) {    if (message instanceof ArrayBuffer) {      // Handle binary upload      const bytes = new Uint8Array(message);      await this.processFile(bytes);      connection.send(        JSON.stringify({ status: "received", size: bytes.length }),      );    } else if (typeof message === "string") {      // Handle text command      const command = JSON.parse(message);      // ...    }  }}
```

Note

Agents automatically send JSON text frames (identity, state, MCP servers) to every connection. If your client only handles binary data and cannot process these frames, use [shouldSendProtocolMessages](https://developers.cloudflare.com/agents/runtime/communication/protocol-messages/) to suppress them.

## Error and close handling

Handle connection errors and disconnections. The `onError` method has two overloads — one for WebSocket connection errors and one for server-level errors:

* [  JavaScript ](#tab-panel-6227)
* [  TypeScript ](#tab-panel-6228)

JavaScript

```
export class ChatAgent extends Agent {  // WebSocket connection error
  // Server-level error (not tied to a specific connection)
  onError(connectionOrError, error) {    if (error) {      console.error(`Connection ${connectionOrError.id} error:`, error);    } else {      console.error("Server error:", connectionOrError);    }  }
  async onClose(connection, code, reason, wasClean) {    console.log(`Connection ${connection.id} closed: ${code} ${reason}`);
    this.broadcast(      JSON.stringify({        event: "user-left",        userId: connection.state?.userId,      }),    );  }}
```

TypeScript

```
export class ChatAgent extends Agent {  // WebSocket connection error  onError(connection: Connection, error: unknown): void;  // Server-level error (not tied to a specific connection)  onError(error: unknown): void;  onError(connectionOrError: Connection | unknown, error?: unknown) {    if (error) {      console.error(        `Connection ${(connectionOrError as Connection).id} error:`,        error,      );    } else {      console.error("Server error:", connectionOrError);    }  }
  async onClose(    connection: Connection,    code: number,    reason: string,    wasClean: boolean,  ) {    console.log(`Connection ${connection.id} closed: ${code} ${reason}`);
    this.broadcast(      JSON.stringify({        event: "user-left",        userId: connection.state?.userId,      }),    );  }}
```

The default `onError` implementation logs the error and rethrows it. Override it to add custom error handling, reporting, or recovery logic.

## Message types

| Type            | Description                     |
| --------------- | ------------------------------- |
| string          | Text message (typically JSON)   |
| ArrayBuffer     | Binary data                     |
| ArrayBufferView | Typed array view of binary data |

## Hibernation

Agents support hibernation — they can sleep when inactive and wake when needed. This saves resources while maintaining WebSocket connections.

### Enabling hibernation

Hibernation is enabled by default. To disable:

* [  JavaScript ](#tab-panel-6211)
* [  TypeScript ](#tab-panel-6212)

JavaScript

```
export class AlwaysOnAgent extends Agent {  static options = { hibernate: false };}
```

TypeScript

```
export class AlwaysOnAgent extends Agent {  static options = { hibernate: false };}
```

### How hibernation works

1. Agent is active, handling connections
2. After a period of inactivity with no messages, the agent hibernates (sleeps)
3. WebSocket connections remain open (handled by Cloudflare)
4. When a message arrives, the agent wakes up
5. `onMessage` is called as normal

### What persists across hibernation

| Persists                 | Does not persist    |
| ------------------------ | ------------------- |
| this.state (agent state) | In-memory variables |
| connection.state         | Timers/intervals    |
| SQLite data (this.sql)   | Promises in flight  |
| Connection metadata      | Local caches        |

Store important data in `this.state` or SQLite, not in class properties:

* [  JavaScript ](#tab-panel-6223)
* [  TypeScript ](#tab-panel-6224)

JavaScript

```
export class MyAgent extends Agent {  initialState = { counter: 0 };
  // Do not do this - lost on hibernation  localCounter = 0;
  onMessage(connection, message) {    // Persists across hibernation    this.setState({ counter: this.state.counter + 1 });
    // Lost after hibernation    this.localCounter++;  }}
```

TypeScript

```
export class MyAgent extends Agent<Env, { counter: number }> {  initialState = { counter: 0 };
  // Do not do this - lost on hibernation  private localCounter = 0;
  onMessage(connection: Connection, message: WSMessage) {    // Persists across hibernation    this.setState({ counter: this.state.counter + 1 });
    // Lost after hibernation    this.localCounter++;  }}
```

## Common patterns

### Presence tracking

Track who is online using per-connection state. Connection state is automatically cleaned up when users disconnect:

* [  JavaScript ](#tab-panel-6231)
* [  TypeScript ](#tab-panel-6232)

JavaScript

```
export class PresenceAgent extends Agent {  onConnect(connection, ctx) {    const url = new URL(ctx.request.url);    const name = url.searchParams.get("name") || "Anonymous";
    connection.setState({      name,      joinedAt: Date.now(),      lastSeen: Date.now(),    });
    // Send current presence to new user    connection.send(      JSON.stringify({        type: "presence",        users: this.getPresence(),      }),    );
    // Notify others that someone joined    this.broadcastPresence();  }
  onClose(connection) {    // No manual cleanup needed - connection state is automatically gone    this.broadcastPresence();  }
  onMessage(connection, message) {    if (message === "ping") {      connection.setState((prev) => ({        ...prev,        lastSeen: Date.now(),      }));      connection.send("pong");    }  }
  getPresence() {    const users = {};    for (const conn of this.getConnections()) {      if (conn.state) {        users[conn.id] = {          name: conn.state.name,          lastSeen: conn.state.lastSeen,        };      }    }    return users;  }
  broadcastPresence() {    this.broadcast(      JSON.stringify({        type: "presence",        users: this.getPresence(),      }),    );  }}
```

TypeScript

```
type UserState = {  name: string;  joinedAt: number;  lastSeen: number;};
export class PresenceAgent extends Agent {  onConnect(connection: Connection<UserState>, ctx: ConnectionContext) {    const url = new URL(ctx.request.url);    const name = url.searchParams.get("name") || "Anonymous";
    connection.setState({      name,      joinedAt: Date.now(),      lastSeen: Date.now(),    });
    // Send current presence to new user    connection.send(      JSON.stringify({        type: "presence",        users: this.getPresence(),      }),    );
    // Notify others that someone joined    this.broadcastPresence();  }
  onClose(connection: Connection) {    // No manual cleanup needed - connection state is automatically gone    this.broadcastPresence();  }
  onMessage(connection: Connection<UserState>, message: WSMessage) {    if (message === "ping") {      connection.setState((prev) => ({        ...prev!,        lastSeen: Date.now(),      }));      connection.send("pong");    }  }
  private getPresence() {    const users: Record<string, { name: string; lastSeen: number }> = {};    for (const conn of this.getConnections<UserState>()) {      if (conn.state) {        users[conn.id] = {          name: conn.state.name,          lastSeen: conn.state.lastSeen,        };      }    }    return users;  }
  private broadcastPresence() {    this.broadcast(      JSON.stringify({        type: "presence",        users: this.getPresence(),      }),    );  }}
```

### Chat room with broadcast

* [  JavaScript ](#tab-panel-6229)
* [  TypeScript ](#tab-panel-6230)

JavaScript

```
export class ChatRoom extends Agent {  onConnect(connection, ctx) {    const url = new URL(ctx.request.url);    const username = url.searchParams.get("username") || "Anonymous";
    connection.setState({ username });
    // Notify others    this.broadcast(      JSON.stringify({        type: "join",        user: username,        timestamp: Date.now(),      }),      [connection.id], // Do not send to the joining user    );  }
  onMessage(connection, message) {    if (typeof message !== "string") return;
    const { username } = connection.state;
    this.broadcast(      JSON.stringify({        type: "message",        user: username,        text: message,        timestamp: Date.now(),      }),    );  }
  onClose(connection) {    const { username } = connection.state || {};    if (username) {      this.broadcast(        JSON.stringify({          type: "leave",          user: username,          timestamp: Date.now(),        }),      );    }  }}
```

TypeScript

```
type Message = {  type: "message" | "join" | "leave";  user: string;  text?: string;  timestamp: number;};
export class ChatRoom extends Agent {  onConnect(connection: Connection, ctx: ConnectionContext) {    const url = new URL(ctx.request.url);    const username = url.searchParams.get("username") || "Anonymous";
    connection.setState({ username });
    // Notify others    this.broadcast(      JSON.stringify({        type: "join",        user: username,        timestamp: Date.now(),      } satisfies Message),      [connection.id], // Do not send to the joining user    );  }
  onMessage(connection: Connection, message: WSMessage) {    if (typeof message !== "string") return;
    const { username } = connection.state as { username: string };
    this.broadcast(      JSON.stringify({        type: "message",        user: username,        text: message,        timestamp: Date.now(),      } satisfies Message),    );  }
  onClose(connection: Connection) {    const { username } = (connection.state as { username: string }) || {};    if (username) {      this.broadcast(        JSON.stringify({          type: "leave",          user: username,          timestamp: Date.now(),        } satisfies Message),      );    }  }}
```

## Suppressing protocol messages

By default, agents send JSON text frames (identity, state sync, MCP server lists) to every connection. Override `shouldSendProtocolMessages` to suppress them for specific connections — for example, binary-only clients that cannot handle JSON text frames:

* [  JavaScript ](#tab-panel-6225)
* [  TypeScript ](#tab-panel-6226)

JavaScript

```
export class IoTAgent extends Agent {  shouldSendProtocolMessages(connection, ctx) {    const url = new URL(ctx.request.url);    return url.searchParams.get("protocol") !== "binary";  }}
```

TypeScript

```
export class IoTAgent extends Agent {  shouldSendProtocolMessages(    connection: Connection,    ctx: ConnectionContext,  ): boolean {    const url = new URL(ctx.request.url);    return url.searchParams.get("protocol") !== "binary";  }}
```

When this returns `false`, the connection does not receive identity, state, or MCP server list frames — neither on connect nor via broadcasts. The connection can still send and receive regular messages, use RPC, and participate in all non-protocol communication.

Use `isConnectionProtocolEnabled(connection)` to check the status of any connection at runtime.

## Agent properties

These properties are available on `this` inside any Agent method:

| Property   | Type               | Description                                                               |
| ---------- | ------------------ | ------------------------------------------------------------------------- |
| this.name  | string             | The instance name of this agent                                           |
| this.state | State              | The current agent state (lazy-loaded from SQLite)                         |
| this.env   | Env                | Worker environment bindings                                               |
| this.ctx   | DurableObjectState | Durable Object context (storage, alarms, etc.)                            |
| this.sql   | template tag       | SQL template tag for executing queries against the agent's SQLite storage |
| this.mcp   | MCPClientManager   | MCP client manager for connecting to external MCP servers                 |

## Connecting from clients

For browser connections, use the Agents client SDK:

* **Vanilla JS**: `AgentClient` from `agents/client`
* **React**: `useAgent` hook from `agents/react`

Refer to [Client SDK](https://developers.cloudflare.com/agents/communication-channels/chat/client-sdk/) for full documentation.

## Next steps

[ State synchronization ](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) Sync state between agents and clients. 

[ Callable methods ](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/) RPC over WebSockets for method calls. 

[ Cross-domain authentication ](https://developers.cloudflare.com/agents/runtime/operations/cross-domain-authentication/) Secure WebSocket connections across domains.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/communication/websockets/#page","headline":"WebSockets · Cloudflare Agents docs","description":"Handle real-time WebSocket connections, messages, broadcasts, and lifecycle hooks in the Agents SDK.","url":"https://developers.cloudflare.com/agents/runtime/communication/websockets/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/communication/","name":"Communication"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/communication/websockets/","name":"WebSockets"}}]}
```

---

---
title: Agent Skills
description: Give an agent a catalog of on-demand instructions, resources, and scripts with agents/skills, activated by the model only when a task matches.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Agent Skills

Agent Skills are on-demand instructions, resources, and scripts. A skill source provides a catalog of skill names and descriptions; the agent adds that catalog to the system prompt and exposes tools the model can use when a user task matches a skill — so a large library of capabilities does not bloat every prompt.

Note

Agent Skills are experimental, and script execution in particular is early. The API may change in a future release.

The skills engine lives in `agents/skills` and is framework-agnostic, so any agent (including a plain [AIChatAgent](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/) `onChatMessage`) can build a `SkillRegistry`. [@cloudflare/think](https://developers.cloudflare.com/agents/harnesses/think/) re-exports it as the `skills` namespace and wires `getSkills()` into the turn automatically.

## Using skills with Think

Bundled skills are usually imported with the Agents Vite plugin:

* [  JavaScript ](#tab-panel-6239)
* [  TypeScript ](#tab-panel-6240)

JavaScript

```
import { Think, skills } from "@cloudflare/think";import bundledSkills from "agents:skills"; // resolves to ./skills next to this file
export class MyAgent extends Think {  getSkills() {    return [      bundledSkills,      skills.r2(this.env.SKILLS_BUCKET, { prefix: "skills/" }),    ];  }
  getSkillScriptRunner() {    return skills.runner({      loader: this.env.LOADER,      workspaceInstance: this.workspace,    });  }}
```

TypeScript

```
import { Think, skills } from "@cloudflare/think";import bundledSkills from "agents:skills"; // resolves to ./skills next to this file
type Env = {  AI: Ai;  LOADER: WorkerLoader;  SKILLS_BUCKET: R2Bucket;};
export class MyAgent extends Think<Env> {  getSkills() {    return [      bundledSkills,      skills.r2(this.env.SKILLS_BUCKET, { prefix: "skills/" }),    ];  }
  getSkillScriptRunner() {    return skills.runner({      loader: this.env.LOADER,      workspaceInstance: this.workspace,    });  }}
```

`agents:skills` resolves to a `./skills` directory next to the importing file; use `agents:skills/<dir>` to point at a differently named sibling directory. The `agents:skills` import is typed by ambient declarations that ship with `agents`, so importing `Think` in the same file brings the type into scope (for a file that imports only the specifier, add `/// <reference types="agents/skills-module" />`). If you are not using the Agents Vite plugin, build a source with `skills.fromManifest(...)` instead.

Sources are applied in order; the first source to register a skill name wins, and later duplicates (or a source that fails to load) are skipped with a logged warning rather than failing the agent.

The imported directory should contain one child directory per skill:

```
src/skills/release-notes/SKILL.mdsrc/skills/release-notes/scripts/format-release-notes.tssrc/skills/release-notes/references/style-guide.md
```

## Skill tools

When skills are available, the agent exposes:

| Tool                  | Purpose                                                           |
| --------------------- | ----------------------------------------------------------------- |
| activate\_skill       | Load a matching skill's instructions and bundled resource list    |
| read\_skill\_resource | Read a bundled resource by { name, path } or skill-name/path      |
| run\_skill\_script    | Run a bundled script when getSkillScriptRunner() returns a runner |

Skills are not always-on system prompt text. Use `getSystemPrompt()` or a Session context block for behavior that should apply to every turn. Use skills for task-specific procedures, references, scripts, templates, and assets that should be loaded only when relevant.

## Script execution

Script execution is opt-in and requires a Worker Loader binding:

* [  wrangler.jsonc ](#tab-panel-6233)
* [  wrangler.toml ](#tab-panel-6234)

JSONC

```
{  "worker_loaders": [{ "binding": "LOADER" }]}
```

TOML

```
[[worker_loaders]]binding = "LOADER"
```

`skills.runner()` is experimental and runs JavaScript, TypeScript, Python, and Bash scripts under `scripts/`. TypeScript is compiled with `@cloudflare/worker-bundler`; Python runs as Python Dynamic Workers; Bash runs through `just-bash`.

JavaScript and TypeScript scripts are function-style:

* [  JavaScript ](#tab-panel-6235)
* [  TypeScript ](#tab-panel-6236)

JavaScript

```
export default async function run(input, ctx) {  const guide = ctx.files["references/style-guide.md"]; // bundled text resources  const docs = await ctx.workspace.readFile("README.md"); // gated by permission  const summary = await ctx.tools.call("summarize", { input }); // explicit tools  await ctx.output.writeFile("notes.md", summary); // scratch artifact  return { ok: true };}
```

TypeScript

```
import type { SkillRunContext } from "@cloudflare/think";
export default async function run(input: unknown, ctx: SkillRunContext) {  const guide = ctx.files["references/style-guide.md"]; // bundled text resources  const docs = await ctx.workspace.readFile("README.md"); // gated by permission  const summary = await ctx.tools.call("summarize", { input }); // explicit tools  await ctx.output.writeFile("notes.md", summary); // scratch artifact  return { ok: true };}
```

`ctx` is `{ skill, files, workspace, tools, output }`. `ctx.files` holds bundled text resources by relative path, `ctx.workspace` is gated by the workspace permission, `ctx.tools` only exposes tools the runner was given, and `ctx.output.writeFile(name, content)` returns scratch artifacts to the model (it does not mutate the workspace). Python and Bash use the path-based contract instead: `/input.json`, `/context.json`, bundled resources under `/skill`, and `/output` for artifacts.

Passing `workspaceInstance` gives scripts read-only workspace access by default. Network access, tools, and workspace writes are opt-in. The default timeout is 30 seconds.

## Example

* [  JavaScript ](#tab-panel-6237)
* [  TypeScript ](#tab-panel-6238)

JavaScript

```
import { Think, skills } from "@cloudflare/think";
export class SkillsAgent extends Think {  getSkills() {    return [skills.r2(this.env.SKILLS_BUCKET, { prefix: "skills/" })];  }}
```

TypeScript

```
import { Think, skills } from "@cloudflare/think";
export class SkillsAgent extends Think<Env> {  getSkills() {    return [skills.r2(this.env.SKILLS_BUCKET, { prefix: "skills/" })];  }}
```

Refer to the [agent-skills example ↗](https://github.com/cloudflare/agents/tree/main/examples/agent-skills) for bundled skills, R2-backed skills, and script execution.

## Related

* [Think](https://developers.cloudflare.com/agents/harnesses/think/) — wires `getSkills()` and `getSkillScriptRunner()` into the agentic loop
* [Think tools](https://developers.cloudflare.com/agents/harnesses/think/tools/) — how skill tools merge with workspace, custom, MCP, and client tools

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/execution/agent-skills/#page","headline":"Agent Skills · Cloudflare Agents docs","description":"Give an agent a catalog of on-demand instructions, resources, and scripts with agents/skills, activated by the model only when a task matches.","url":"https://developers.cloudflare.com/agents/runtime/execution/agent-skills/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/execution/","name":"Execution"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/execution/agent-skills/","name":"Agent Skills"}}]}
```

---

---
title: Agents as tools
description: Run Think and AIChatAgent sub-agents as retained, streaming tools from a parent agent.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Agents as tools

Agents as tools let one chat agent dispatch another chat-capable sub-agent as part of its work. The child is a real sub-agent with its own Durable Object storage, messages, tools, resumable stream, and drill-in URL. The parent keeps a small run registry so clients can render the child timeline, replay it after refresh, and clean it up later.

Agents as tools support `@cloudflare/think` agents and `AIChatAgent` subclasses. `AIChatAgent` children run headlessly through `saveMessages()`, so they should use server-side tools. Browser-provided client tools are not available during an agent-tool turn unless you model that interaction as server-side state or a separate parent-mediated workflow.

## Agents as tools vs sub-agent RPC

Use `subAgent(...).chat()` when parent code needs direct streaming RPC to a specific child and your code owns forwarding, cancellation, and replay policy.

Use `agentTool()` or `runAgentTool()` when a parent model or workflow delegates work to a child agent and you want retained child runs, event replay, abort bridging, and UI drill-in. For Think-specific turn choices, refer to [Choose a turn API](https://developers.cloudflare.com/agents/harnesses/think/#choose-a-turn-api).

## Use an agent as an AI SDK tool

Use `agentTool()` when the parent model should decide when to call the helper.

* [  JavaScript ](#tab-panel-6251)
* [  TypeScript ](#tab-panel-6252)

JavaScript

```
import { Think } from "@cloudflare/think";import { agentTool } from "agents/agent-tools";import { z } from "zod";
export class Researcher extends Think {  getSystemPrompt() {    return "Research the user's topic and end with a concise summary.";  }}
export class Assistant extends Think {  getTools() {    return {      research: agentTool(Researcher, {        description: "Research one topic in depth.",        displayName: "Researcher",        inputSchema: z.object({          query: z.string().min(3),        }),      }),    };  }}
```

TypeScript

```
import { Think } from "@cloudflare/think";import { agentTool } from "agents/agent-tools";import { z } from "zod";
export class Researcher extends Think<Env> {  getSystemPrompt() {    return "Research the user's topic and end with a concise summary.";  }}
export class Assistant extends Think<Env> {  getTools() {    return {      research: agentTool(Researcher, {        description: "Research one topic in depth.",        displayName: "Researcher",        inputSchema: z.object({          query: z.string().min(3),        }),      }),    };  }}
```

The child can also be an `AIChatAgent`:

* [  JavaScript ](#tab-panel-6271)
* [  TypeScript ](#tab-panel-6272)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { agentTool } from "agents/agent-tools";import { convertToModelMessages, stepCountIs, streamText } from "ai";import { z } from "zod";
export class Summarizer extends AIChatAgent {  formatAgentToolInput(input, request) {    return {      id: `agent-tool-${request.runId}-input`,      role: "user",      parts: [{ type: "text", text: `Summarize:\n\n${input.text}` }],    };  }
  async onChatMessage() {    const result = streamText({      model: this.env.MODEL,      messages: await convertToModelMessages(this.messages),    });    return result.toUIMessageStreamResponse();  }}
export class Assistant extends AIChatAgent {  async onChatMessage() {    const result = streamText({      model: this.env.MODEL,      messages: await convertToModelMessages(this.messages),      tools: {        summarize: agentTool(Summarizer, {          description: "Summarize long text in a separate retained agent.",          inputSchema: z.object({ text: z.string() }),        }),      },      stopWhen: stepCountIs(5),    });
    return result.toUIMessageStreamResponse();  }}
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { agentTool } from "agents/agent-tools";import { convertToModelMessages, stepCountIs, streamText } from "ai";import { z } from "zod";
export class Summarizer extends AIChatAgent<Env> {  protected override formatAgentToolInput(input: { text: string }, request) {    return {      id: `agent-tool-${request.runId}-input`,      role: "user",      parts: [{ type: "text", text: `Summarize:\n\n${input.text}` }],    };  }
  async onChatMessage() {    const result = streamText({      model: this.env.MODEL,      messages: await convertToModelMessages(this.messages),    });    return result.toUIMessageStreamResponse();  }}
export class Assistant extends AIChatAgent<Env> {  async onChatMessage() {    const result = streamText({      model: this.env.MODEL,      messages: await convertToModelMessages(this.messages),      tools: {        summarize: agentTool(Summarizer, {          description: "Summarize long text in a separate retained agent.",          inputSchema: z.object({ text: z.string() }),        }),      },      stopWhen: stepCountIs(5),    });
    return result.toUIMessageStreamResponse();  }}
```

The generated tool calls `this.runAgentTool(ChildAgent, ...)`, streams `agent-tool-event` frames on the parent WebSocket, and returns the child summary to the parent model. If the run fails, aborts, or is interrupted, the tool returns a structured `AgentToolFailure` instead of an empty success value:

TypeScript

```
type AgentToolFailure = {  ok: false;  status: "error" | "aborted" | "interrupted";  error: string; // human-readable, safe to surface  retryable: boolean;  // Present only when `status` is "interrupted":  reason?: AgentToolInterruptedReason;  childStillRunning?: boolean;};
type AgentToolInterruptedReason =  | "no-progress"  | "window-exceeded"  | "not-tailable"  | "inspect-timeout"  | "inspect-failed"  | "recovery-deadline"  | "budget-exceeded";
```

`retryable` is `true` only for an `interrupted` run — the child was reset or superseded by a deploy or parent recovery and never reached a logical outcome, so re-dispatching the same call can succeed. A genuine `error` or an intentional `aborted` is `retryable: false`. This lets a parent prompt convention or an orchestration harness re-run a transient interruption rather than reporting it to the user as a final failure. `AgentToolFailure` is exported from `agents`.

On an `interrupted` run, `reason` gives a machine-readable cause and `childStillRunning` reports whether the child was still working when the parent stopped waiting (`true`) or has since been torn down (`false`). Branch on these instead of parsing the `error` prose — for example, re-dispatch a `no-progress` interrupt (the child may still self-heal) but reconnect to or surface a `window-exceeded` one (the child was torn down). Both `reason` and `childStillRunning` are also mirrored onto the `agent-tool-event` wire frame and the `useAgentToolEvents()` run state.

For Think children that do workflow-style work without user-facing assistant text, override `getAgentToolOutput()` and, if needed, `getAgentToolSummary()`. Assistant text remains the default summary when present, but a Think agent-tool run can complete successfully without emitting text chunks.

Persist any structured output before the child turn finishes, because `getAgentToolOutput()` is read as soon as `saveMessages()` resolves. Keep `getAgentToolSummary()` concise for display; the full structured value is stored separately as the tool output.

* [  JavaScript ](#tab-panel-6245)
* [  TypeScript ](#tab-panel-6246)

JavaScript

```
export class Extractor extends Think {  getAgentToolOutput(runId) {    const rows = this.sql`      SELECT result_json FROM extraction_runs WHERE id = ${runId}    `;    return rows[0] ? JSON.parse(rows[0].result_json) : undefined;  }
  getAgentToolSummary(_runId, output) {    return output ? "Extraction complete" : "";  }}
```

TypeScript

```
export class Extractor extends Think<Env> {  protected override getAgentToolOutput(runId: string) {    const rows = this.sql<{ result_json: string }>`      SELECT result_json FROM extraction_runs WHERE id = ${runId}    `;    return rows[0] ? JSON.parse(rows[0].result_json) : undefined;  }
  protected override getAgentToolSummary(_runId: string, output: unknown) {    return output ? "Extraction complete" : "";  }}
```

## Run an agent tool imperatively

Use `runAgentTool()` for deterministic workflows, scheduled work, HTTP handlers, or fan-out code.

* [  JavaScript ](#tab-panel-6247)
* [  TypeScript ](#tab-panel-6248)

JavaScript

```
const [a, b] = await Promise.allSettled([  this.runAgentTool(Researcher, {    input: { query: "HTTP/3" },    parentToolCallId: toolCallId,    displayOrder: 0,  }),  this.runAgentTool(Researcher, {    input: { query: "gRPC" },    parentToolCallId: toolCallId,    displayOrder: 1,  }),]);
```

TypeScript

```
const [a, b] = await Promise.allSettled([  this.runAgentTool(Researcher, {    input: { query: "HTTP/3" },    parentToolCallId: toolCallId,    displayOrder: 0,  }),  this.runAgentTool(Researcher, {    input: { query: "gRPC" },    parentToolCallId: toolCallId,    displayOrder: 1,  }),]);
```

`runAgentTool()` is idempotent by `runId`. Passing the same `runId` never starts a duplicate child turn. Completed, failed, aborted, and interrupted runs are retained until you explicitly clear them.

## Detached (background) runs

By default `runAgentTool()` **awaits** the child to terminal before returning. For long-running work — large imports, video renders, deep research — that you do not want to block the dispatching turn on, pass `detached`. The run is dispatched, the current turn continues, and `runAgentTool()` returns a handle immediately:

TypeScript

```
type DetachedRunAgentToolResult = {  runId: string;  agentType: string;  status: "running" | "error"; // "error" only if dispatch itself was rejected};
```

`detached: true` is fire-and-forget — observe the run through `agent-tool-event` frames (the same ones `useAgentToolEvents()` consumes) and the global `onAgentToolFinish()` hook. Pass an object to wire a targeted, durable completion callback:

* [  JavaScript ](#tab-panel-6265)
* [  TypeScript ](#tab-panel-6266)

JavaScript

```
export class Importer extends Think {  async startImport(input) {    const { runId } = await this.runAgentTool(ImportAgent, {      input,      detached: { onFinish: "onImportDone", maxBudgetMs: 60 * 60 * 1000 },    });    return runId;  }
  // Fires once, even if the Durable Object was evicted and rehydrated while the  // child ran. Referenced by METHOD NAME (like schedule()) — never a closure,  // which cannot survive eviction.  async onImportDone(run, result) {    switch (result.status) {      case "completed":        await this.markImportReady(run.runId, result.summary);        break;      case "error":        await this.markImportFailed(run.runId, result.error);        break;      case "interrupted":        // reason "budget-exceeded" ⇒ the run hit its maxBudgetMs ceiling.        // interrupted is soft: a child that finishes anyway re-fires this        // hook with "completed", so make the handler idempotent.        break;    }  }}
```

TypeScript

```
export class Importer extends Think<Env> {  async startImport(input: ImportInput) {    const { runId } = await this.runAgentTool(ImportAgent, {      input,      detached: { onFinish: "onImportDone", maxBudgetMs: 60 * 60 * 1000 },    });    return runId;  }
  // Fires once, even if the Durable Object was evicted and rehydrated while the  // child ran. Referenced by METHOD NAME (like schedule()) — never a closure,  // which cannot survive eviction.  async onImportDone(run: AgentToolRunInfo, result: AgentToolLifecycleResult) {    switch (result.status) {      case "completed":        await this.markImportReady(run.runId, result.summary);        break;      case "error":        await this.markImportFailed(run.runId, result.error);        break;      case "interrupted":        // reason "budget-exceeded" ⇒ the run hit its maxBudgetMs ceiling.        // interrupted is soft: a child that finishes anyway re-fires this        // hook with "completed", so make the handler idempotent.        break;    }  }}
```

Key behaviors:

* **Durable completion.** Delivery survives eviction and deploys: a warm fast path delivers with low latency while the isolate is alive, and a self-scheduling reconcile backbone finalizes anything the fast path missed. Delivery is exactly-once on the happy path; under a crash it is at-least-once, so `onFinish` handlers must be idempotent.
* **Give-up vs. finish are independent.** A budget give-up is delivered as `status: "interrupted"`, `reason: "budget-exceeded"`. Because `interrupted` is soft, a child that completes after the give-up still re-fires `onFinish` with the real result — a premature give-up never hides a late completion.
* **Bounded.** Every detached run has an absolute `maxBudgetMs` ceiling (per-run, or the `detachedMaxBudgetMs` static option; default 24h). On expiry the parent gives up watching and tears the child down so an abandoned run cannot hold a `maxConcurrentAgentTools` slot forever.
* **No inherited signal.** A detached run must outlive the spawning turn, so it does **not** inherit `options.signal`. Cancel it explicitly:

* [  JavaScript ](#tab-panel-6241)
* [  TypeScript ](#tab-panel-6242)

JavaScript

```
await this.cancelAgentTool(runId); // idempotent; delivers onFinish "aborted"
```

TypeScript

```
await this.cancelAgentTool(runId); // idempotent; delivers onFinish "aborted"
```

### Notify the chat on completion (Think / AIChatAgent)

On a chat agent (`@cloudflare/think` or `AIChatAgent`) you usually want the model to _react_ to a finished background run. Instead of wiring `onFinish` by hand, pass `notify: true` — when the run finishes the agent injects a message into the chat (idempotent per run + status, so an exactly-once finish never duplicates) and the model takes its next turn with the result in context:

* [  JavaScript ](#tab-panel-6243)
* [  TypeScript ](#tab-panel-6244)

JavaScript

```
await this.runAgentTool(ResearchAgent, { input, detached: { notify: true } });
```

TypeScript

```
await this.runAgentTool(ResearchAgent, { input, detached: { notify: true } });
```

If your app routes or hides synthetic messages by `metadata.source`, pass your own source:

* [  JavaScript ](#tab-panel-6249)
* [  TypeScript ](#tab-panel-6250)

JavaScript

```
await this.runAgentTool(ResearchAgent, {  input,  detached: { notify: { source: "research-background" } },});
```

TypeScript

```
await this.runAgentTool(ResearchAgent, {  input,  detached: { notify: { source: "research-background" } },});
```

Override `formatDetachedCompletion(run, result)` to customize the injected text, or return an empty string to suppress the notification for a given outcome. An explicit `onFinish` takes precedence over `notify`.

### The `inspectAgentToolRun` contract

A child's `inspectAgentToolRun(runId)` returns the run's current status snapshot, or `null`. **`null` does not mean "failed"** — it means the child has no record of that run _yet_. This is normal immediately after dispatch (the child may still be persisting its first row) and is also what a freshly-rehydrated child returns before it has lazily reconciled a stale `running` row. Callers — and the framework's own reconcile backbone — treat `null` as "not terminal, keep watching within budget", never as a terminal failure. Only a non-`null` inspection with a terminal `status` (`completed` / `error` / `aborted`) finalizes a run.

## Report progress and milestones

A sub-agent running as an agent tool — awaited or detached — can report mid-run progress so a parent can render a live status line, meter the run server-side, or react to a named checkpoint before the run finishes. Call `reportProgress()` from inside the child (for example, from a tool's `execute`):

* [  JavaScript ](#tab-panel-6261)
* [  TypeScript ](#tab-panel-6262)

JavaScript

```
export class ImportAgent extends Think {  getTools() {    return {      ingest: tool({        inputSchema: z.object({ url: z.string() }),        execute: async ({ url }) => {          // Ephemeral progress: drives a generic bar / phase / status line.          await this.reportProgress({            fraction: 0.6,            phase: "ingesting",            message: "Ingested 40k/80k rows",          });          // ...        },      }),    };  }}
```

TypeScript

```
export class ImportAgent extends Think<Env> {  getTools() {    return {      ingest: tool({        inputSchema: z.object({ url: z.string() }),        execute: async ({ url }) => {          // Ephemeral progress: drives a generic bar / phase / status line.          await this.reportProgress({            fraction: 0.6,            phase: "ingesting",            message: "Ingested 40k/80k rows",          });          // ...        },      }),    };  }}
```

`reportProgress()` is available on chat agents (`@cloudflare/think` and `AIChatAgent`). It is a no-op with a development warning on the base `Agent` class and when called outside an active agent-tool run, so the same child code is safe to run standalone. The framework resolves the active run from the current turn — you never thread a run ID.

TypeScript

```
reportProgress<T>(  progress: {    fraction?: number; // 0..1 — drives a progress bar    message?: string; // human-readable status line    phase?: string; // coarse phase label, e.g. "ingesting"    milestone?: string; // present ⇒ a durable milestone (see below)    data?: T; // app-specific payload; live-only unless persisted  },  options?: { persist?: boolean },): Promise<void>;
```

Ephemeral signals ride the child's own turn stream as a transient `data-agent-progress` part, so they re-broadcast to the parent's connected clients and surface on `AgentToolRunState.progress` through `useAgentToolEvents()` — a background-runs tray can render a live bar, phase, and status line without drilling in. Bursts are coalesced (latest-wins; a `fraction >= 1` frame always flushes). The `data` field is live-only unless you pass `{ persist: true }`.

### Observe progress on the parent

Override `onProgress()` to meter, steer, or surface progress server-side. It fires best-effort whenever a child progress signal is forwarded through the parent, for both awaited and detached runs:

* [  JavaScript ](#tab-panel-6255)
* [  TypeScript ](#tab-panel-6256)

JavaScript

```
export class Assistant extends Think {  async onProgress(run, progress) {    if (progress.milestone) {      // A durable milestone landed — branch on it.    }    console.log(run.runId, progress.phase, progress.fraction);  }}
```

TypeScript

```
export class Assistant extends Think<Env> {  override async onProgress(    run: AgentToolRunInfo,    progress: AgentToolProgressSnapshot,  ) {    if (progress.milestone) {      // A durable milestone landed — branch on it.    }    console.log(run.runId, progress.phase, progress.fraction);  }}
```

`onProgress()` is not durable: after eviction a detached run's latest snapshot is reconstructed from `inspectAgentToolRun().progress` on reconcile rather than re-firing the hook. The latest snapshot is also persisted on the child run row, so a rehydrated parent can answer "where is this run" without having tailed the live stream.

### Durable milestones

Naming a `milestone` promotes a signal from the ephemeral tier to a **durable** one — there is still only one emit method:

* [  JavaScript ](#tab-panel-6253)
* [  TypeScript ](#tab-panel-6254)

JavaScript

```
await this.reportProgress({  milestone: "sources-gathered",  data: { sources: 2 },});
```

TypeScript

```
await this.reportProgress({  milestone: "sources-gathered",  data: { sources: 2 },});
```

A milestone is persisted as one row on the child with a monotonic per-run `sequence`, and rides the stream as a **persisted** `data-agent-milestone` part (unlike transient progress). It therefore survives eviction, replays on drill-in, and is surfaced — deduped by `sequence` — on `AgentToolRunState.milestones` and `inspectAgentToolRun().milestones`. `onProgress()` fires for milestones too, with `progress.milestone` set, so a consumer can branch on milestone versus ephemeral progress.

### Notify the chat on a milestone (Think / AIChatAgent)

For a detached run on a chat agent, `detached: { onMilestones }` surfaces a chat message when a configured milestone lands, _before_ the run finishes. Each `(runId, name)` fires at most once — whether observed live or reconciled after eviction — so the deterministic ID collapses warm and cold delivery to at-most-once:

* [  JavaScript ](#tab-panel-6263)
* [  TypeScript ](#tab-panel-6264)

JavaScript

```
// "narrate" (default): inject a synthetic assistant status line — no model turn.await this.runAgentTool(Researcher, {  input,  detached: { onMilestones: ["sources-gathered"] },});
// "react": post a user-role turn so the model responds (steer, start dependent// work). Costs a model turn.await this.runAgentTool(Researcher, {  input,  detached: { onMilestones: { names: ["needs-approval"], mode: "react" } },});
```

TypeScript

```
// "narrate" (default): inject a synthetic assistant status line — no model turn.await this.runAgentTool(Researcher, {  input,  detached: { onMilestones: ["sources-gathered"] },});
// "react": post a user-role turn so the model responds (steer, start dependent// work). Costs a model turn.await this.runAgentTool(Researcher, {  input,  detached: { onMilestones: { names: ["needs-approval"], mode: "react" } },});
```

Override `formatDetachedMilestone(run, milestone)` to customize the wording, or return an empty string to suppress a given milestone. Synthetic narrate messages carry `metadata.source`, so clients can render them as an agent event rather than a human turn.

### Resetting no-progress budget for detached runs

Once a detached child has reported at least one signal, the reconcile backbone gives up if the run then goes silent for `detachedNoProgressBudgetMs` (default 1 hour; per-run override via `detached: { noProgressBudgetMs }`). This surfaces as `status: "interrupted"`, `reason: "no-progress"`. A child that never reports is bounded only by the absolute `detachedMaxBudgetMs` ceiling — a run is never given up on merely for being slow. Set `noProgressBudgetMs` to `0` or `Infinity` to disable the resetting window for a run.

## Render child timelines in React

`useAgentToolEvents()` is a headless hook. It subscribes to the existing parent connection, deduplicates replay/live races, applies child `UIMessageChunk` bodies to message parts, and groups sibling runs by parent tool call ID. Each run state carries `progress` and `milestones`, so a background-runs tray can render a live bar, phase, and milestone chips without drilling in.

* [  JavaScript ](#tab-panel-6269)
* [  TypeScript ](#tab-panel-6270)

JavaScript

```
import { useAgent, useAgentToolEvents } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
const agent = useAgent({ agent: "Assistant", name: userId });const { messages } = useAgentChat({ agent });const agentTools = useAgentToolEvents({ agent });
for (const message of messages) {  for (const part of message.parts) {    if (part.type === "tool-call") {      const runs = agentTools.getRunsForToolCall(part.toolCallId);      // Render the child runs beside this tool call.    }  }}
```

TypeScript

```
import { useAgent, useAgentToolEvents } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
const agent = useAgent({ agent: "Assistant", name: userId });const { messages } = useAgentChat({ agent });const agentTools = useAgentToolEvents({ agent });
for (const message of messages) {  for (const part of message.parts) {    if (part.type === "tool-call") {      const runs = agentTools.getRunsForToolCall(part.toolCallId);      // Render the child runs beside this tool call.    }  }}
```

Imperative runs without a parent tool call are available as `agentTools.unboundRuns`.

## Drill in and gate access

Agents as tools are normal sub-agents. Connect to a retained child through the parent route:

* [  JavaScript ](#tab-panel-6257)
* [  TypeScript ](#tab-panel-6258)

JavaScript

```
useAgent({  agent: "Assistant",  name: userId,  sub: [{ agent: "Researcher", name: runId }],});
```

TypeScript

```
useAgent({  agent: "Assistant",  name: userId,  sub: [{ agent: "Researcher", name: runId }],});
```

Gate external access with the parent registry so guessed run IDs cannot spawn fresh child facets:

TypeScript

```
override async onBeforeSubAgent(_request, child) {  if (!this.hasAgentToolRun(child.className, child.name)) {    return new Response("Not found", { status: 404 });  }}
```

## Clear retained runs

Runs and child facets are retained by default for refresh, drill-in, and later inspection. Delete them explicitly when clearing chat history or applying your own retention policy:

* [  JavaScript ](#tab-panel-6259)
* [  TypeScript ](#tab-panel-6260)

JavaScript

```
await this.clearAgentToolRuns();await this.clearAgentToolRuns({  status: ["completed", "error", "aborted", "interrupted"],});await this.clearAgentToolRuns({ olderThan: Date.now() - 7 * 24 * 60 * 60_000 });
```

TypeScript

```
await this.clearAgentToolRuns();await this.clearAgentToolRuns({  status: ["completed", "error", "aborted", "interrupted"],});await this.clearAgentToolRuns({ olderThan: Date.now() - 7 * 24 * 60 * 60_000 });
```

If a retained run is still `starting` or `running`, cleanup cancels the child before deleting its facet.

## Interrupted runs and recovery

Agent-tool runs are retained in the parent. If the parent restarts (deploy or eviction) while a child run is still `starting` or `running`, it does not abandon the child. Startup recovery re-attaches to the live child and tails its stream to the child's terminal result. Because the child is a sub-agent with its own `chatRecovery`, it self-heals its own interrupted turn while the parent forwards its output. A completed child is finalized without re-running finished work.

The re-attach wait is **progress-keyed**, not a fixed wall clock. Two static `options` tune it:

| Option                               | Default        | Behavior                                                                                                                                                                                                      |
| ------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| agentToolReattachNoProgressTimeoutMs | 120000 (2 min) | How long the parent waits with **no** forward progress before giving up. Resets on every forwarded chunk, so a streaming child is followed through to terminal.                                               |
| agentToolReattachMaxWindowMs         | Infinity       | Optional hard wall-clock ceiling on a single re-attach. Uncapped by default (mirrors chat recovery's maxRecoveryWork), so a healthy, long-running child is never cut off. Set a finite value to impose a cap. |

Give-up outcomes map to the `AgentToolFailure` fields:

* A child that goes silent for a full no-progress window is sealed `reason: "no-progress"`, `childStillRunning: true`. This seal is soft: the child is left running, so re-dispatching the same `runId` can re-attach and collect it if it self-heals.
* If you set a finite `agentToolReattachMaxWindowMs` and it fires, the run is sealed `reason: "window-exceeded"`, `childStillRunning: false`, and the child is torn down (it has had its full window and is treated as exhausted).
* A child that cannot be tailed or inspected, or that exceeds the overall recovery deadline, is sealed with the matching `reason` so the parent tool call returns a structured failure instead of hanging indefinitely.

A hung child can never block recovery forever. The no-progress budget bounds a silent child. A content runaway is bounded by the child's own `chatRecovery` (`maxRecoveryWork` and `shouldKeepRecovering`), not by a parent-only timer.

Monitor parent reconciliation through the `agentTool` observability channel:

* [  JavaScript ](#tab-panel-6267)
* [  TypeScript ](#tab-panel-6268)

JavaScript

```
import { subscribe } from "agents/observability";
const unsubscribe = subscribe("agentTool", (event) => {  if (event.type === "agent_tool:recovery:row") {    console.log("Recovered agent-tool row", event.payload);  }});
```

TypeScript

```
import { subscribe } from "agents/observability";
const unsubscribe = subscribe("agentTool", (event) => {  if (event.type === "agent_tool:recovery:row") {    console.log("Recovered agent-tool row", event.payload);  }});
```

Raw `diagnostics_channel` subscribers should use the channel name `agents:agent_tool`.

## Example

[ Agents as tools example ](https://github.com/cloudflare/agents/tree/main/examples/agents-as-tools) Run chat-capable sub-agents as retained tools, stream their timelines inline, and drill into child agents. 

## Related

[ Sub-agents ](https://developers.cloudflare.com/agents/runtime/execution/sub-agents/) Spawn child agents with isolated storage, typed RPC, and nested client routing. 

[ Chat agents ](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/) Build AI chat interfaces with AIChatAgent and useAgentChat.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/execution/agent-tools/#page","headline":"Agents as tools · Cloudflare Agents docs","description":"Run Think and AIChatAgent sub-agents as retained, streaming tools from a parent agent.","url":"https://developers.cloudflare.com/agents/runtime/execution/agent-tools/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-26","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/execution/","name":"Execution"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/execution/agent-tools/","name":"Agents as tools"}}]}
```

---

---
title: Durable execution with fibers
description: Run work that survives Durable Object eviction with runFiber(), startFiber(), keepAlive(), and crash recovery.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Durable execution with fibers

Run work that survives Durable Object eviction. `runFiber()` registers a task in SQLite, keeps the agent alive during execution, lets you checkpoint intermediate state with `stash()`, and calls `onFiberRecovered()` on the next activation if the agent was evicted mid-task.

Use `startFiber()` when a caller needs to durably accept background work, return quickly, safely dedupe retries, inspect status later, or cancel a running job.

Note

For how fibers fit into the bigger picture of building agents that run for weeks or months, refer to [Long-running agents](https://developers.cloudflare.com/agents/concepts/agentic-patterns/long-running-agents/).

## Quick start

TypeScript

```
import { Agent } from "agents";import type { FiberRecoveryContext } from "agents";
class MyAgent extends Agent {  async doWork() {    await this.runFiber("my-task", async (ctx) => {      const step1 = await expensiveOperation();      ctx.stash({ step1 });
      const step2 = await anotherExpensiveOperation(step1);      this.setState({ ...this.state, result: step2 });    });  }
  async onFiberRecovered(ctx: FiberRecoveryContext) {    if (ctx.name !== "my-task") return;    const snapshot = ctx.snapshot as { step1: unknown } | null;    if (snapshot) {      const step2 = await anotherExpensiveOperation(snapshot.step1);      this.setState({ ...this.state, result: step2 });    }  }}
```

## Why fibers exist

Durable Objects get evicted for three reasons:

1. **Inactivity timeout** — \~70–140 seconds with no incoming requests or open WebSockets
2. **Code updates / runtime restarts** — non-deterministic, 1–2x per day
3. **Alarm handler timeout** — 15 minutes

When eviction happens mid-work, the upstream HTTP connection (to an LLM provider, an API, a database) is severed permanently. In-memory state — streaming buffers, partial responses, loop counters — is lost. Multi-turn agent loops lose their position entirely.

`keepAlive()` reduces the chance of eviction. `runFiber()` makes eviction survivable.

For work that should run independently of the agent with per-step retries and multi-step orchestration, use [Workflows](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/) instead. Fibers are for work that is part of the agent's own execution. Refer to [Long-running agents: Workflows vs agent-internal patterns](https://developers.cloudflare.com/agents/concepts/agentic-patterns/long-running-agents/#when-to-use-workflows-vs-agent-internal-patterns) for a comparison.

## keepAlive

Prevents idle eviction by creating a 30-second alarm heartbeat that resets the inactivity timer.

TypeScript

```
class Agent {  keepAlive(): Promise<() => void>;  keepAliveWhile<T>(fn: () => Promise<T>): Promise<T>;}
```

`keepAliveWhile()` is the recommended approach — it runs an async function and automatically cleans up the heartbeat when it completes or throws:

TypeScript

```
const result = await this.keepAliveWhile(async () => {  return await slowAPICall();});
```

For manual control, `keepAlive()` returns a disposer. Always call it when done — otherwise the heartbeat continues indefinitely:

TypeScript

```
const dispose = await this.keepAlive();try {  await longWork();} finally {  dispose();}
```

### How it works

While any `keepAlive` ref is held, an alarm fires every 30 seconds that resets the inactivity timer. When all disposers are called, alarms stop and the DO can go idle naturally.

The heartbeat is invisible to `listSchedules()` — no schedule rows are created. It does not conflict with your own schedules; the alarm system multiplexes all schedules and the keepAlive heartbeat through a single alarm slot.

### Configurable interval

Default: 30 seconds. The inactivity timeout is \~70–140 seconds, so 30 seconds gives comfortable margin. Override via static options:

TypeScript

```
class MyAgent extends Agent {  static options = { keepAliveIntervalMs: 2_000 };}
```

### When to use keepAlive vs runFiber

`keepAlive` prevents eviction but does nothing about recovery. If the agent _is_ evicted despite the heartbeat (code update, alarm timeout, resource limit), any in-progress work is lost.

`runFiber` calls `keepAlive` internally _and_ persists the work in SQLite so it can be recovered. Use `keepAlive` alone when the work is cheap to redo or does not need checkpointing. Use `runFiber` when the work is expensive and you need to resume from where you left off.

| Scenario                                         | Use                     |
| ------------------------------------------------ | ----------------------- |
| Waiting on a slow API call                       | keepAlive()             |
| Streaming an LLM response (via AIChatAgent)      | Automatic (built in)    |
| Multi-step computation with intermediate results | runFiber()              |
| Background research loop that takes 10+ minutes  | runFiber() with stash() |
| Webhook job that must be accepted exactly once   | startFiber()            |

## runFiber

Durable execution with checkpointing and recovery.

TypeScript

```
class Agent {  runFiber<T>(name: string, fn: (ctx: FiberContext) => Promise<T>): Promise<T>;  startFiber(    name: string,    fn: (ctx: FiberContext) => Promise<void>,    options?: StartFiberOptions,  ): Promise<StartFiberResult>;  inspectFiber(fiberId: string): Promise<FiberInspection | null>;  inspectFiberByKey(idempotencyKey: string): Promise<FiberInspection | null>;  listFibers(options?: ListFibersOptions): Promise<FiberInspection[]>;  cancelFiber(fiberId: string, reason?: string): Promise<boolean>;  cancelFiberByKey(idempotencyKey: string, reason?: string): Promise<boolean>;  deleteFibers(options?: DeleteFibersOptions): Promise<number>;  resolveFiber(fiberId: string, result: FiberRecoveryResult): Promise<boolean>;  stash(data: unknown): void;  onFiberRecovered(    ctx: FiberRecoveryContext,  ): Promise<void | FiberRecoveryResult>;}
type FiberContext = {  id: string;  signal: AbortSignal;  stash(data: unknown): void;  snapshot: unknown | null;};
type FiberStatus =  | "pending"  | "running"  | "completed"  | "aborted"  | "interrupted"  | "error";
type FiberRecoveryContext = {  id: string;  name: string;  status?: FiberStatus;  idempotencyKey?: string;  metadata?: Record<string, unknown> | null;  snapshot: unknown | null;  createdAt: number;  recoveryReason: "interrupted";};
```

### Lifecycle

#### Normal execution

```
runFiber("work", fn)  ├─ Persist recovery metadata  ├─ keepAlive() — heartbeat starts  ├─ Execute fn(ctx)  │    ├─ ctx.stash(data) → persist snapshot  │    ├─ ctx.stash(data) → persist snapshot  │    └─ return result  ├─ Delete recovery metadata  ├─ keepAlive dispose — heartbeat stops  └─ Return result to caller
```

#### Eviction and recovery

```
[DO evicted — all in-memory state lost]
  On next activation:  ├─ Request/connection → onStart() → check for orphaned fibers  [primary path]  │  OR  ├─ Persisted alarm fires → housekeeping check                   [fallback path]
  Recovery:  ├─ Load interrupted fibers from storage  ├─ For each interrupted fiber:  │    ├─ Parse snapshot from JSON  │    ├─ Call onFiberRecovered(ctx)  │    └─ Delete recovery metadata after successful recovery  └─ If onFiberRecovered calls runFiber() again → new fiber, normal execution
```

Both recovery paths call the same hook. The alarm path is critical for background agents that have no incoming client connections — the persisted alarm wakes the agent on its own.

#### Sub-agents

Fibers also work inside sub-agents. The fiber row and snapshots are stored in the sub-agent's own SQLite database, and `onFiberRecovered()` runs with the sub-agent as `this`.

Sub-agents do not have independent alarm slots, so the top-level parent owns the physical heartbeat. When a sub-agent starts a fiber, the parent tracks enough metadata to route recovery checks back into the owning sub-agent, even if the child has no client connection or incoming RPC.

This keeps recovery local to the child while preserving the single physical alarm slot owned by the parent. A recovered continuation can use `schedule()` from inside the facet; the parent owns the physical alarm and routes the callback back to the child.

#### Error during execution

```
fn(ctx) throws Error  ├─ DELETE row from cf_agents_runs  ├─ keepAlive dispose  └─ Error propagates to caller (or logged if fire-and-forget)
```

No automatic retries. Recovery logic belongs in `onFiberRecovered`, where you have the snapshot and full context about what went wrong.

### Inline vs fire-and-forget

`runFiber()` supports both patterns:

TypeScript

```
// Inline — await the resultconst result = await this.runFiber("work", async (ctx) => {  return computeExpensiveThing();});
// Fire-and-forget — caller does not waitvoid this.runFiber("background", async (ctx) => {  await longRunningProcess();});
```

If the DO is evicted during an inline `await`, the caller is gone. On recovery, `onFiberRecovered` fires — it cannot return a result to the original caller. This is the inherent limitation of durable execution across process boundaries. For long-running work that is likely to outlive a single DO lifetime, use `startFiber()` when callers need a retained status record, idempotent acceptance, or cancellation.

## startFiber

Use `startFiber()` when a caller needs to durably accept background work, return quickly, and safely dedupe retries. It stores a retained fiber record before the callback runs, then starts the callback in the background using the same keep-alive and recovery machinery as `runFiber()`.

TypeScript

```
const receipt = await this.startFiber(  "reply-to-webhook",  async (ctx) => {    ctx.stash({ webhookId, threadId });    await postReply(threadId);  },  {    idempotencyKey: `webhook:${webhookId}`,    metadata: { threadId },  },);
if (!receipt.accepted) {  // This webhook was already accepted by an earlier delivery.}
```

By default, `startFiber()` returns after the work is durably accepted. Pass `waitForCompletion: true` when the caller should remain open until the accepted fiber reaches a terminal status. Duplicate calls with the same idempotency key join an active in-memory execution when possible, then return the retained status with `accepted: false`.

TypeScript

```
const result = await this.startFiber("reply-to-webhook", reply, {  idempotencyKey: `webhook:${webhookId}`,  waitForCompletion: true,});
if (result.status === "error") {  console.error(result.error);}
```

`startFiber()` is a durable acceptance API, not a value-return API. It returns the managed fiber status, but not the callback's result. Inspect status later with `inspectFiber()` or `inspectFiberByKey()`.

TypeScript

```
const current = await this.inspectFiberByKey(`webhook:${webhookId}`);
if (current) {  await this.cancelFiber(current.fiberId, "No longer needed");}
await this.deleteFibers({  status: ["completed", "error", "aborted"],  settledBefore: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),});
```

By default, `deleteFibers()` deletes settled `completed`, `error`, and `aborted` rows. It does not delete `interrupted` rows unless you pass that status explicitly, because interrupted rows often need inspection or manual resolution.

Cancellation is cooperative. `cancelFiber()` records an aborted terminal state and aborts `ctx.signal` if the fiber is running in the current isolate. Your callback should check `ctx.signal.aborted` around expensive work and before visible side effects. Callers using `waitForCompletion: true` return when the ledger reaches `aborted`, even if a non-cooperative callback keeps running in the current isolate.

If the Durable Object is evicted mid-fiber, the retained record is marked `interrupted` and `onFiberRecovered()` receives the last checkpoint. The original closure cannot be replayed automatically; use `ctx.name`, `ctx.snapshot`, and metadata to decide whether to resume, compensate, or leave the record for inspection.

Return a `FiberRecoveryResult` from `onFiberRecovered()` to record the policy decision:

TypeScript

```
async onFiberRecovered(ctx: FiberRecoveryContext) {  if (ctx.name !== "reply-to-webhook") return;
  const snapshot = ctx.snapshot as { webhookId: string; threadId: string };  await postRecoveryMessage(snapshot.threadId);
  return {    status: "completed",    snapshot: { ...snapshot, recovered: true },  };}
```

Returning `undefined` keeps a managed fiber `interrupted`. Throwing leaves it `interrupted` and records the recovery error for inspection. Terminal managed fibers such as `aborted` are not recovered again if a stale run row remains.

If recovery is triggered by a later duplicate webhook instead of `onFiberRecovered()`, use `resolveFiber()` with the same result shape after your application-level recovery succeeds. `resolveFiber()` only updates managed fibers that are currently `interrupted`; it returns `false` for pending, running, or already-terminal rows.

## Checkpoints with stash

`ctx.stash(data)` writes to SQLite **synchronously**. There is no async gap between "I decided to save" and "it is saved." If eviction happens after `stash()` returns, the data is guaranteed to be in SQLite.

Each call **fully replaces** the previous snapshot — it is not a merge. Write the complete recovery state you need:

TypeScript

```
await this.runFiber("research", async (ctx) => {  const steps = ["search", "analyze", "synthesize"];  const completed: string[] = [];  const results: Record<string, unknown> = {};
  for (const step of steps) {    results[step] = await executeStep(step);    completed.push(step);
    ctx.stash({      completed,      results,      pendingSteps: steps.slice(completed.length),    });  }});
```

### this.stash vs ctx.stash

Both do the same thing. `ctx.stash()` uses a direct closure over the fiber ID. `this.stash()` uses `AsyncLocalStorage` to find the currently executing fiber — it works correctly even with concurrent fibers, since each fiber's ALS context is independent.

`this.stash()` is convenient when calling from nested functions that do not have access to `ctx`. It throws if called outside a `runFiber` callback.

## Recovery

Override `onFiberRecovered` to handle interrupted fibers. The default implementation logs a warning and deletes the row.

TypeScript

```
class ResearchAgent extends Agent {  async onFiberRecovered(ctx: FiberRecoveryContext) {    if (ctx.name !== "research") return;
    const snapshot = ctx.snapshot as {      completed: string[];      results: Record<string, unknown>;      pendingSteps: string[];    } | null;
    if (snapshot && snapshot.pendingSteps.length > 0) {      void this.runFiber("research", async (fiberCtx) => {        const { completed, results, pendingSteps } = snapshot;
        for (const step of pendingSteps) {          results[step] = await this.executeStep(step);          completed.push(step);
          fiberCtx.stash({            completed,            results,            pendingSteps: pendingSteps.slice(pendingSteps.indexOf(step) + 1),          });        }      });    }  }}
```

Key points:

* **The original lambda is gone.** On recovery, you only have the `name` and `snapshot`. The lambda cannot be serialized — recovery logic must be in the hook.
* **Unmanaged `runFiber()` rows are deleted after the hook returns successfully.** If you want to continue unmanaged work, call `runFiber()` again inside the hook — this creates a new row.
* **Managed `startFiber()` rows are retained.** Return a `FiberRecoveryResult` to mark an interrupted managed fiber as `completed`, `error`, `aborted`, or still `interrupted`.
* **You control what recovery means.** Retry from the beginning, resume from a checkpoint, skip and notify the user, or do nothing. The framework does not impose a strategy.
* **If the hook throws, the row is kept (up to a bound).** A later startup or alarm scan retries recovery, which protects against transient storage or scheduling failures. Catch application-level errors yourself when you want to mark the work terminal instead of retrying. A hook that always throws is retried on a backing-off schedule (the recovery alarm uses an exponential delay capped at 5 minutes, so it is not a busy-loop) until the row exceeds `fiberRecoveryMaxAgeMs` (default 24 h), after which it is discarded with a `fiber:recovery:skipped` (`reason: "max_age_exceeded"`) event. Setting `fiberRecoveryMaxAgeMs: 0` retains such rows indefinitely — recovery keeps retrying on the capped backoff, and the Durable Object never idle-evicts while an un-recoverable row exists, so prefer a finite age unless you intend to inspect or clear those rows yourself. For managed work, the retained row stays `interrupted` and records the recovery error for inspection.

### Chat recovery

`AIChatAgent` builds on fibers for LLM streaming recovery. When `chatRecovery` is enabled, each chat turn is wrapped in a fiber automatically. The framework handles the internal recovery path and exposes `onChatRecovery` for provider-specific strategies. Refer to [Long-running agents: Recovering interrupted LLM streams](https://developers.cloudflare.com/agents/concepts/agentic-patterns/long-running-agents/#recovering-interrupted-llm-streams) for details.

## Concurrent fibers

Multiple fibers can run at the same time. Each has its own row in SQLite with its own snapshot, and each calls `keepAlive()` independently (ref-counted, so the DO stays alive until all fibers complete).

TypeScript

```
void this.runFiber("fetch-data", async (ctx) => {  /* ... */});void this.runFiber("process-queue", async (ctx) => {  /* ... */});
```

On recovery, all orphaned rows are iterated and `onFiberRecovered` is called for each. Use `ctx.name` to distinguish between fiber types in your recovery hook.

## Testing locally

In `wrangler dev`, fiber recovery works identically to production. SQLite and alarm state persist to disk between restarts.

1. Start your agent and trigger a fiber (`runFiber`)
2. Kill the wrangler process (Ctrl-C or SIGKILL)
3. Restart wrangler
4. Recovery fires automatically — via `onStart()` if a request arrives, or via the persisted alarm if no clients connect

## API reference

### runFiber(name, fn)

Execute a durable fiber. The fiber is registered in SQLite before `fn` runs and deleted after it completes (or throws). `keepAlive()` is held for the duration.

* **`name`** — identifier for the fiber, used in `onFiberRecovered` to distinguish fiber types. Not unique — multiple fibers can share a name.
* **`fn`** — async function receiving a `FiberContext`. Closures work naturally (`this` and local variables are captured).
* **Returns** — the value returned by `fn`. If the DO is evicted before completion, the return value is lost; recovery happens through the hook.

### startFiber(name, fn, options)

Durably accept a retained background fiber. The returned `StartFiberResult` includes a generated `fiberId`, current `status`, optional `metadata`, and `accepted`, which is `false` when an existing fiber matched the same idempotency key.

* **`name`** — identifier for the managed fiber, used in inspection and recovery.
* **`fn`** — async function receiving a `FiberContext`. The function result is not stored.
* **`options.idempotencyKey`** — stable external key used to dedupe retries.
* **`options.metadata`** — JSON-serializable data stored with the retained row.
* **`options.waitForCompletion`** — wait for terminal status before returning.

### inspectFiber(fiberId) / inspectFiberByKey(idempotencyKey)

Return the retained status row for a managed fiber, or `null` if no row exists.

### listFibers(options)

List retained managed fibers. Filter by `status` or `name`, and use `limit` to cap the result set.

### cancelFiber(fiberId, reason) / cancelFiberByKey(idempotencyKey, reason)

Mark a managed fiber as `aborted` and abort its in-memory `ctx.signal` when it is running in the current isolate. Returns `false` if the fiber does not exist or is already terminal.

### resolveFiber(fiberId, result)

Resolve an `interrupted` managed fiber after application-level recovery succeeds. Returns `false` for pending, running, or already-terminal rows.

### deleteFibers(options)

Delete retained managed fiber rows. By default, settled `completed`, `error`, and `aborted` rows are eligible. Pass `status`, `settledBefore`, or `limit` to narrow cleanup.

### stash(data) / ctx.stash(data)

Checkpoint the current fiber's state. Writes synchronously to SQLite. Each call fully replaces the previous snapshot. `data` must be JSON-serializable.

### onFiberRecovered(ctx)

Called once per orphaned fiber row on agent restart. Override to implement recovery. Unmanaged `runFiber()` rows are deleted after this hook returns successfully; if recovery throws, the row is left for a later scan so transient failures do not lose the recovery handle. Managed `startFiber()` rows stay retained and can be resolved by returning a `FiberRecoveryResult`.

* **`ctx.id`** — unique fiber ID
* **`ctx.name`** — the name passed to `runFiber()`
* **`ctx.status`** — retained status for managed fibers
* **`ctx.idempotencyKey`** — idempotency key for managed fibers, if supplied
* **`ctx.metadata`** — metadata for managed fibers, if supplied
* **`ctx.snapshot`** — the last `stash()` data, or `null` if `stash()` was never called
* **`ctx.createdAt`** — epoch milliseconds when `runFiber()` started. Compare against `Date.now()` to skip recoveries that are too old to replay safely.
* **`ctx.recoveryReason`** — why recovery is running. Currently always `"interrupted"` for eviction or restart recovery.

### keepAlive()

Create a 30-second alarm heartbeat. Returns a disposer function. Idempotent — calling the disposer multiple times is safe.

### keepAliveWhile(fn)

Run an async function while keeping the DO alive. Heartbeat starts before `fn` and stops when it completes or throws. Returns the value returned by `fn`.

## Related

* [Long-running agents](https://developers.cloudflare.com/agents/concepts/agentic-patterns/long-running-agents/) — how fibers compose with schedules, plans, and async operations
* [Schedule tasks](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) — `keepAlive` details and the alarm system
* [Sub-agents](https://developers.cloudflare.com/agents/runtime/execution/sub-agents/) — durable execution and schedules inside sub-agents
* [Workflows](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/) — durable multi-step execution outside the agent
* [Chat agents](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/) — `chatRecovery` and `onChatRecovery`

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/execution/durable-execution/#page","headline":"Durable execution with fibers · Cloudflare Agents docs","description":"Run work that survives Durable Object eviction with runFiber(), startFiber(), keepAlive(), and crash recovery.","url":"https://developers.cloudflare.com/agents/runtime/execution/durable-execution/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-16","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/execution/","name":"Execution"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/execution/durable-execution/","name":"Durable execution with fibers"}}]}
```

---

---
title: Queue tasks
description: Add background tasks to a built-in FIFO queue for asynchronous processing within Cloudflare Agents.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Queue tasks

The Agents SDK provides a built-in queue system that allows you to schedule tasks for asynchronous execution. This is useful for background processing, delayed operations, and managing workloads that do not need immediate execution.

## Overview

The queue system is built into the base `Agent` class. Tasks are stored in a SQLite table and processed automatically in FIFO (First In, First Out) order.

## `QueueItem` type

TypeScript

```
type QueueItem<T> = {  id: string; // Unique identifier for the queued task  payload: T; // Data to pass to the callback function  callback: keyof Agent; // Name of the method to call  created_at: number; // Timestamp when the task was created  retry?: RetryOptions; // Retry options for this task};
```

## Core methods

### `queue()`

Adds a task to the queue for future execution.

TypeScript

```
async queue<T>(  callback: keyof this,  payload: T,  options?: { retry?: RetryOptions }): Promise<string>
```

**Parameters:**

* `callback` \- The name of the method to call when processing the task
* `payload` \- Data to pass to the callback method
* `options` \- Optional configuration:  
  * `retry` \- Retry options for the callback execution. If the callback throws, it is retried with exponential backoff. Refer to [Retries](https://developers.cloudflare.com/agents/runtime/execution/retries/) for details on `RetryOptions`

**Returns:** The unique ID of the queued task

**Example:**

* [  JavaScript ](#tab-panel-6283)
* [  TypeScript ](#tab-panel-6284)

JavaScript

```
class MyAgent extends Agent {  async processEmail(data) {    // Process the email    console.log(`Processing email: ${data.subject}`);  }
  async onMessage(message) {    // Queue an email processing task    const taskId = await this.queue("processEmail", {      email: "user@example.com",      subject: "Welcome!",    });
    console.log(`Queued task with ID: ${taskId}`);  }}
```

TypeScript

```
class MyAgent extends Agent {  async processEmail(data: { email: string; subject: string }) {    // Process the email    console.log(`Processing email: ${data.subject}`);  }
  async onMessage(message: string) {    // Queue an email processing task    const taskId = await this.queue("processEmail", {      email: "user@example.com",      subject: "Welcome!",    });
    console.log(`Queued task with ID: ${taskId}`);  }}
```

### `dequeue()`

Removes a specific task from the queue by ID. This method is synchronous.

TypeScript

```
dequeue(id: string): void
```

**Parameters:**

* `id` \- The ID of the task to remove

**Example:**

* [  JavaScript ](#tab-panel-6273)
* [  TypeScript ](#tab-panel-6274)

JavaScript

```
// Remove a specific taskagent.dequeue("abc123def");
```

TypeScript

```
// Remove a specific taskagent.dequeue("abc123def");
```

### `dequeueAll()`

Removes all tasks from the queue. This method is synchronous.

TypeScript

```
dequeueAll(): void
```

**Example:**

* [  JavaScript ](#tab-panel-6275)
* [  TypeScript ](#tab-panel-6276)

JavaScript

```
// Clear the entire queueagent.dequeueAll();
```

TypeScript

```
// Clear the entire queueagent.dequeueAll();
```

### `dequeueAllByCallback()`

Removes all tasks that match a specific callback method. This method is synchronous.

TypeScript

```
dequeueAllByCallback(callback: string): void
```

**Parameters:**

* `callback` \- Name of the callback method

**Example:**

* [  JavaScript ](#tab-panel-6277)
* [  TypeScript ](#tab-panel-6278)

JavaScript

```
// Remove all email processing tasksagent.dequeueAllByCallback("processEmail");
```

TypeScript

```
// Remove all email processing tasksagent.dequeueAllByCallback("processEmail");
```

### `getQueue()`

Retrieves a specific queued task by ID. This method is synchronous.

TypeScript

```
getQueue<T>(id: string): QueueItem<T> | undefined
```

**Parameters:**

* `id` \- The ID of the task to retrieve

**Returns:** The `QueueItem` with parsed payload or `undefined` if not found

The payload is automatically parsed from JSON before being returned.

**Example:**

* [  JavaScript ](#tab-panel-6281)
* [  TypeScript ](#tab-panel-6282)

JavaScript

```
const task = agent.getQueue("abc123def");if (task) {  console.log(`Task callback: ${task.callback}`);  console.log(`Task payload:`, task.payload);}
```

TypeScript

```
const task = agent.getQueue("abc123def");if (task) {  console.log(`Task callback: ${task.callback}`);  console.log(`Task payload:`, task.payload);}
```

### `getQueues()`

Retrieves all queued tasks that match a specific key-value pair in their payload. This method is synchronous.

TypeScript

```
getQueues<T>(key: string, value: string): QueueItem<T>[]
```

**Parameters:**

* `key` \- The key to filter by in the payload
* `value` \- The value to match

**Returns:** Array of matching `QueueItem` objects

This method fetches all queue items and filters them in memory by parsing each payload and checking if the specified key matches the value.

**Example:**

* [  JavaScript ](#tab-panel-6279)
* [  TypeScript ](#tab-panel-6280)

JavaScript

```
// Find all tasks for a specific userconst userTasks = agent.getQueues("userId", "12345");
```

TypeScript

```
// Find all tasks for a specific userconst userTasks = agent.getQueues("userId", "12345");
```

## How queue processing works

1. **Validation**: When calling `queue()`, the method validates that the callback exists as a function on the agent.
2. **Automatic processing**: After queuing, the system automatically attempts to flush the queue.
3. **FIFO order**: Tasks are processed in the order they were created (`created_at` timestamp).
4. **Context preservation**: Each queued task runs with the same agent context (connection, request, email).
5. **Automatic dequeue**: Successfully executed tasks are automatically removed from the queue.
6. **Error handling**: If a callback method does not exist at execution time, an error is logged and the task is skipped.
7. **Persistence**: Tasks are stored in the `cf_agents_queues` SQL table and survive agent restarts.

## Queue callback methods

When defining callback methods for queued tasks, they must follow this signature:

TypeScript

```
async callbackMethod(payload: unknown, queueItem: QueueItem): Promise<void>
```

**Example:**

* [  JavaScript ](#tab-panel-6287)
* [  TypeScript ](#tab-panel-6288)

JavaScript

```
class MyAgent extends Agent {  async sendNotification(payload, queueItem) {    console.log(`Processing task ${queueItem.id}`);    console.log(      `Sending notification to user ${payload.userId}: ${payload.message}`,    );
    // Your notification logic here    await this.notificationService.send(payload.userId, payload.message);  }
  async onUserSignup(userData) {    // Queue a welcome notification    await this.queue("sendNotification", {      userId: userData.id,      message: "Welcome to our platform!",    });  }}
```

TypeScript

```
class MyAgent extends Agent {  async sendNotification(    payload: { userId: string; message: string },    queueItem: QueueItem<{ userId: string; message: string }>,  ) {    console.log(`Processing task ${queueItem.id}`);    console.log(      `Sending notification to user ${payload.userId}: ${payload.message}`,    );
    // Your notification logic here    await this.notificationService.send(payload.userId, payload.message);  }
  async onUserSignup(userData: any) {    // Queue a welcome notification    await this.queue("sendNotification", {      userId: userData.id,      message: "Welcome to our platform!",    });  }}
```

## Use cases

### Background processing

* [  JavaScript ](#tab-panel-6285)
* [  TypeScript ](#tab-panel-6286)

JavaScript

```
class DataProcessor extends Agent {  async processLargeDataset(data) {    const results = await this.heavyComputation(data.datasetId);    await this.notifyUser(data.userId, results);  }
  async onDataUpload(uploadData) {    // Queue the processing instead of doing it synchronously    await this.queue("processLargeDataset", {      datasetId: uploadData.id,      userId: uploadData.userId,    });
    return { message: "Data upload received, processing started" };  }}
```

TypeScript

```
class DataProcessor extends Agent {  async processLargeDataset(data: { datasetId: string; userId: string }) {    const results = await this.heavyComputation(data.datasetId);    await this.notifyUser(data.userId, results);  }
  async onDataUpload(uploadData: any) {    // Queue the processing instead of doing it synchronously    await this.queue("processLargeDataset", {      datasetId: uploadData.id,      userId: uploadData.userId,    });
    return { message: "Data upload received, processing started" };  }}
```

### Batch operations

* [  JavaScript ](#tab-panel-6289)
* [  TypeScript ](#tab-panel-6290)

JavaScript

```
class BatchProcessor extends Agent {  async processBatch(data) {    for (const item of data.items) {      await this.processItem(item);    }    console.log(`Completed batch ${data.batchId}`);  }
  async onLargeRequest(items) {    // Split large requests into smaller batches    const batchSize = 10;    for (let i = 0; i < items.length; i += batchSize) {      const batch = items.slice(i, i + batchSize);      await this.queue("processBatch", {        items: batch,        batchId: `batch-${i / batchSize + 1}`,      });    }  }}
```

TypeScript

```
class BatchProcessor extends Agent {  async processBatch(data: { items: any[]; batchId: string }) {    for (const item of data.items) {      await this.processItem(item);    }    console.log(`Completed batch ${data.batchId}`);  }
  async onLargeRequest(items: any[]) {    // Split large requests into smaller batches    const batchSize = 10;    for (let i = 0; i < items.length; i += batchSize) {      const batch = items.slice(i, i + batchSize);      await this.queue("processBatch", {        items: batch,        batchId: `batch-${i / batchSize + 1}`,      });    }  }}
```

## Error handling

Use the built-in `retry` option instead of manual re-queue logic. When a callback throws, the task is automatically retried with exponential backoff:

* [  JavaScript ](#tab-panel-6291)
* [  TypeScript ](#tab-panel-6292)

JavaScript

```
class RobustAgent extends Agent {  async reliableTask(payload, queueItem) {    console.log(`Processing task ${queueItem.id}`);    const response = await fetch(payload.url);    if (!response.ok) {      throw new Error(`Request failed: ${response.status}`);    }  }
  async onMessage(connection, message) {    await this.queue(      "reliableTask",      { url: "https://api.example.com/data" },      {        retry: {          maxAttempts: 5,          baseDelayMs: 500,          maxDelayMs: 10_000,        },      },    );  }}
```

TypeScript

```
class RobustAgent extends Agent {  async reliableTask(payload: { url: string }, queueItem: QueueItem) {    console.log(`Processing task ${queueItem.id}`);    const response = await fetch(payload.url);    if (!response.ok) {      throw new Error(`Request failed: ${response.status}`);    }  }
  async onMessage(connection: Connection, message: WSMessage) {    await this.queue(      "reliableTask",      { url: "https://api.example.com/data" },      {        retry: {          maxAttempts: 5,          baseDelayMs: 500,          maxDelayMs: 10_000,        },      },    );  }}
```

If no `retry` option is provided, the class-level defaults from `static options.retry` are used (3 attempts, 100ms base delay, 3s max delay). Refer to [Retries](https://developers.cloudflare.com/agents/runtime/execution/retries/) for full details.

## Best practices

1. **Keep payloads small**: Payloads are JSON-serialized and stored in the database.
2. **Idempotent operations**: Design callback methods to be safe to retry.
3. **Error handling**: Include proper error handling in callback methods.
4. **Monitoring**: Use logging to track queue processing.
5. **Cleanup**: Regularly clean up completed or failed tasks if needed.

## Integration with other features

The queue system works with other Agent SDK features:

* **State management**: Access agent state within queued callbacks.
* **Scheduling**: Combine with [schedule()](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) for time-based queue processing.
* **Context**: Queued tasks maintain the original request context.
* **Database**: Uses the same database as other agent data.

## Limitations

* Tasks are processed sequentially, not in parallel.
* No priority system (FIFO only).
* Queue processing happens during agent execution, not as separate background jobs.

## Queue vs Schedule

Use **queue** when you want tasks to execute as soon as possible in order. Use [**schedule**](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) when you need tasks to run at specific times or on a recurring basis.

| Feature          | Queue                    | Schedule                    |
| ---------------- | ------------------------ | --------------------------- |
| Execution timing | Immediate (FIFO)         | Specific time or cron       |
| Use case         | Background processing    | Delayed or recurring tasks  |
| Storage          | cf\_agents\_queues table | cf\_agents\_schedules table |

## Next steps

[ Agents API ](https://developers.cloudflare.com/agents/runtime/agents-api/) Complete API reference for the Agents SDK. 

[ Schedule tasks ](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) Time-based execution with cron and delays. 

[ Run Workflows ](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/) Durable multi-step background processing.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/execution/queue-tasks/#page","headline":"Queue tasks · Cloudflare Agents docs","description":"Add background tasks to a built-in FIFO queue for asynchronous processing within Cloudflare Agents.","url":"https://developers.cloudflare.com/agents/runtime/execution/queue-tasks/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/execution/","name":"Execution"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/execution/queue-tasks/","name":"Queue tasks"}}]}
```

---

---
title: Retries
description: Retry failed operations with exponential backoff and jitter using the built-in retry system in the Agents SDK.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Retries

Retry failed operations with exponential backoff and jitter. The Agents SDK provides built-in retry support for scheduled tasks, queued tasks, and a general-purpose `this.retry()` method for your own code.

## Overview

Transient failures are common when calling external APIs, interacting with other services, or running background tasks. The retry system handles these automatically:

* **Exponential backoff** — each retry waits longer than the last
* **Jitter** — randomized delays prevent thundering herd problems
* **Configurable** — tune attempts, delays, and caps per call site
* **Built-in** — schedule, queue, and workflow operations retry automatically

## Quick start

Use `this.retry()` to retry any async operation:

* [  JavaScript ](#tab-panel-6297)
* [  TypeScript ](#tab-panel-6298)

JavaScript

```
import { Agent } from "agents";
export class MyAgent extends Agent {  async fetchWithRetry(url) {    const response = await this.retry(async () => {      const res = await fetch(url);      if (!res.ok) throw new Error(`HTTP ${res.status}`);      return res.json();    });
    return response;  }}
```

TypeScript

```
import { Agent } from "agents";
export class MyAgent extends Agent {  async fetchWithRetry(url: string) {    const response = await this.retry(async () => {      const res = await fetch(url);      if (!res.ok) throw new Error(`HTTP ${res.status}`);      return res.json();    });
    return response;  }}
```

By default, `this.retry()` retries up to three times with jittered exponential backoff.

## `this.retry()`

The `retry()` method is available on every `Agent` instance. It retries the provided function on any thrown error by default.

TypeScript

```
async retry<T>(  fn: (attempt: number) => Promise<T>,  options?: RetryOptions & {    shouldRetry?: (err: unknown, nextAttempt: number) => boolean;  }): Promise<T>
```

**Parameters:**

* `fn` — the async function to retry. Receives the current attempt number (1-indexed).
* `options` — optional retry configuration (refer to [RetryOptions](#retryoptions) below). Options are validated eagerly — invalid values throw immediately.
* `options.shouldRetry` — optional predicate called with the thrown error and the next attempt number. Return `false` to stop retrying immediately. If not provided, all errors are retried.

**Returns:** the result of `fn` on success.

**Throws:** the last error if all attempts fail or `shouldRetry` returns `false`.

### Examples

**Basic retry:**

* [  JavaScript ](#tab-panel-6293)
* [  TypeScript ](#tab-panel-6294)

JavaScript

```
const data = await this.retry(() => fetch("https://api.example.com/data"));
```

TypeScript

```
const data = await this.retry(() => fetch("https://api.example.com/data"));
```

**Custom retry options:**

* [  JavaScript ](#tab-panel-6299)
* [  TypeScript ](#tab-panel-6300)

JavaScript

```
const data = await this.retry(  async () => {    const res = await fetch("https://slow-api.example.com/data");    if (!res.ok) throw new Error(`HTTP ${res.status}`);    return res.json();  },  {    maxAttempts: 5,    baseDelayMs: 500,    maxDelayMs: 10000,  },);
```

TypeScript

```
const data = await this.retry(  async () => {    const res = await fetch("https://slow-api.example.com/data");    if (!res.ok) throw new Error(`HTTP ${res.status}`);    return res.json();  },  {    maxAttempts: 5,    baseDelayMs: 500,    maxDelayMs: 10000,  },);
```

**Using the attempt number:**

* [  JavaScript ](#tab-panel-6295)
* [  TypeScript ](#tab-panel-6296)

JavaScript

```
const result = await this.retry(async (attempt) => {  console.log(`Attempt ${attempt}...`);  return await this.callExternalService();});
```

TypeScript

```
const result = await this.retry(async (attempt) => {  console.log(`Attempt ${attempt}...`);  return await this.callExternalService();});
```

**Selective retry with `shouldRetry`:**

Use `shouldRetry` to stop retrying on specific errors. The predicate receives both the error and the next attempt number:

* [  JavaScript ](#tab-panel-6305)
* [  TypeScript ](#tab-panel-6306)

JavaScript

```
const data = await this.retry(  async () => {    const res = await fetch("https://api.example.com/data");    if (!res.ok) throw new HttpError(res.status, await res.text());    return res.json();  },  {    maxAttempts: 5,    shouldRetry: (err, nextAttempt) => {      // Do not retry 4xx client errors — our request is wrong      if (err instanceof HttpError && err.status >= 400 && err.status < 500) {        return false;      }      return true; // retry everything else (5xx, network errors, etc.)    },  },);
```

TypeScript

```
const data = await this.retry(  async () => {    const res = await fetch("https://api.example.com/data");    if (!res.ok) throw new HttpError(res.status, await res.text());    return res.json();  },  {    maxAttempts: 5,    shouldRetry: (err, nextAttempt) => {      // Do not retry 4xx client errors — our request is wrong      if (err instanceof HttpError && err.status >= 400 && err.status < 500) {        return false;      }      return true; // retry everything else (5xx, network errors, etc.)    },  },);
```

## Retries in schedules

Pass retry options when creating a schedule:

* [  JavaScript ](#tab-panel-6321)
* [  TypeScript ](#tab-panel-6322)

JavaScript

```
// Retry up to 5 times if the callback failsawait this.schedule(  "processTask",  60,  { taskId: "123" },  {    retry: { maxAttempts: 5 },  },);
// Retry with custom backoffawait this.schedule(  new Date("2026-03-01T09:00:00Z"),  "sendReport",  {},  {    retry: {      maxAttempts: 3,      baseDelayMs: 1000,      maxDelayMs: 30000,    },  },);
// Cron with retriesawait this.schedule(  "0 8 * * *",  "dailyDigest",  {},  {    retry: { maxAttempts: 3 },  },);
// Interval with retriesawait this.scheduleEvery(  30,  "poll",  { source: "api" },  {    retry: { maxAttempts: 5, baseDelayMs: 200 },  },);
```

TypeScript

```
// Retry up to 5 times if the callback failsawait this.schedule(  "processTask",  60,  { taskId: "123" },  {    retry: { maxAttempts: 5 },  },);
// Retry with custom backoffawait this.schedule(  new Date("2026-03-01T09:00:00Z"),  "sendReport",  {},  {    retry: {      maxAttempts: 3,      baseDelayMs: 1000,      maxDelayMs: 30000,    },  },);
// Cron with retriesawait this.schedule(  "0 8 * * *",  "dailyDigest",  {},  {    retry: { maxAttempts: 3 },  },);
// Interval with retriesawait this.scheduleEvery(  30,  "poll",  { source: "api" },  {    retry: { maxAttempts: 5, baseDelayMs: 200 },  },);
```

If the callback throws, it is retried according to the retry options. If all attempts fail, the error is logged and routed through `onError()`. The schedule is still removed (for one-time schedules) or rescheduled (for cron/interval) regardless of success or failure.

## Retries in queues

Pass retry options when adding a task to the queue:

* [  JavaScript ](#tab-panel-6309)
* [  TypeScript ](#tab-panel-6310)

JavaScript

```
await this.queue(  "sendEmail",  { to: "user@example.com" },  {    retry: { maxAttempts: 5 },  },);
await this.queue("processWebhook", webhookData, {  retry: {    maxAttempts: 3,    baseDelayMs: 500,    maxDelayMs: 5000,  },});
```

TypeScript

```
await this.queue(  "sendEmail",  { to: "user@example.com" },  {    retry: { maxAttempts: 5 },  },);
await this.queue("processWebhook", webhookData, {  retry: {    maxAttempts: 3,    baseDelayMs: 500,    maxDelayMs: 5000,  },});
```

If the callback throws, it is retried before the task is dequeued. After all attempts are exhausted, the task is dequeued and the error is logged.

## Validation

Retry options are validated eagerly when you call `this.retry()`, `queue()`, `schedule()`, or `scheduleEvery()`. Invalid options throw immediately instead of failing later at execution time:

* [  JavaScript ](#tab-panel-6315)
* [  TypeScript ](#tab-panel-6316)

JavaScript

```
// Throws immediately: "retry.maxAttempts must be >= 1"await this.queue("sendEmail", data, {  retry: { maxAttempts: 0 },});
// Throws immediately: "retry.baseDelayMs must be > 0"await this.schedule(  60,  "process",  {},  {    retry: { baseDelayMs: -100 },  },);
// Throws immediately: "retry.maxAttempts must be an integer"await this.retry(() => fetch(url), { maxAttempts: 2.5 });
// Throws immediately: "retry.baseDelayMs must be <= retry.maxDelayMs"// because baseDelayMs: 5000 exceeds the default maxDelayMs: 3000await this.queue("sendEmail", data, {  retry: { baseDelayMs: 5000 },});
```

TypeScript

```
// Throws immediately: "retry.maxAttempts must be >= 1"await this.queue("sendEmail", data, {  retry: { maxAttempts: 0 },});
// Throws immediately: "retry.baseDelayMs must be > 0"await this.schedule(  60,  "process",  {},  {    retry: { baseDelayMs: -100 },  },);
// Throws immediately: "retry.maxAttempts must be an integer"await this.retry(() => fetch(url), { maxAttempts: 2.5 });
// Throws immediately: "retry.baseDelayMs must be <= retry.maxDelayMs"// because baseDelayMs: 5000 exceeds the default maxDelayMs: 3000await this.queue("sendEmail", data, {  retry: { baseDelayMs: 5000 },});
```

Validation resolves partial options against class-level or built-in defaults before checking cross-field constraints. This means `{ baseDelayMs: 5000 }` is caught immediately when the resolved `maxDelayMs` is 3000, rather than failing later at execution time.

## Default behavior

Even without explicit retry options, scheduled and queued callbacks are retried with sensible defaults:

| Setting     | Default |
| ----------- | ------- |
| maxAttempts | 3       |
| baseDelayMs | 100     |
| maxDelayMs  | 3000    |

These defaults apply to `this.retry()`, `queue()`, `schedule()`, and `scheduleEvery()`. Per-call-site options override them.

### Class-level defaults

Override the defaults for your entire agent via `static options`:

* [  JavaScript ](#tab-panel-6301)
* [  TypeScript ](#tab-panel-6302)

JavaScript

```
class MyAgent extends Agent {  static options = {    retry: { maxAttempts: 5, baseDelayMs: 200, maxDelayMs: 5000 },  };}
```

TypeScript

```
class MyAgent extends Agent {  static options = {    retry: { maxAttempts: 5, baseDelayMs: 200, maxDelayMs: 5000 },  };}
```

You only need to specify the fields you want to change — unset fields fall back to the built-in defaults:

* [  JavaScript ](#tab-panel-6303)
* [  TypeScript ](#tab-panel-6304)

JavaScript

```
class MyAgent extends Agent {  // Only override maxAttempts; baseDelayMs (100) and maxDelayMs (3000) stay default  static options = {    retry: { maxAttempts: 10 },  };}
```

TypeScript

```
class MyAgent extends Agent {  // Only override maxAttempts; baseDelayMs (100) and maxDelayMs (3000) stay default  static options = {    retry: { maxAttempts: 10 },  };}
```

Class-level defaults are used as fallbacks when a call site does not specify retry options. Per-call-site options always take priority:

* [  JavaScript ](#tab-panel-6307)
* [  TypeScript ](#tab-panel-6308)

JavaScript

```
// Uses class-level defaults (10 attempts)await this.retry(() => fetch(url));
// Overrides to 2 attempts for this specific callawait this.retry(() => fetch(url), { maxAttempts: 2 });
```

TypeScript

```
// Uses class-level defaults (10 attempts)await this.retry(() => fetch(url));
// Overrides to 2 attempts for this specific callawait this.retry(() => fetch(url), { maxAttempts: 2 });
```

To disable retries for a specific task, set `maxAttempts: 1`:

* [  JavaScript ](#tab-panel-6313)
* [  TypeScript ](#tab-panel-6314)

JavaScript

```
await this.schedule(  60,  "oneShot",  {},  {    retry: { maxAttempts: 1 },  },);
```

TypeScript

```
await this.schedule(  60,  "oneShot",  {},  {    retry: { maxAttempts: 1 },  },);
```

## RetryOptions

TypeScript

```
interface RetryOptions {  /** Maximum number of attempts (including the first). Must be an integer >= 1. Default: 3 */  maxAttempts?: number;  /** Base delay in milliseconds for exponential backoff. Must be > 0 and <= maxDelayMs. Default: 100 */  baseDelayMs?: number;  /** Maximum delay cap in milliseconds. Must be > 0. Default: 3000 */  maxDelayMs?: number;}
```

The delay between retries uses **full jitter exponential backoff**:

```
delay = random(0, min(2^attempt * baseDelayMs, maxDelayMs))
```

This means early retries are fast (often under 200ms), and later retries back off to avoid overwhelming a failing service. The randomization (jitter) prevents multiple agents from retrying at the exact same moment.

## How it works

### Backoff strategy

The retry system uses the "Full Jitter" strategy from the [AWS Architecture Blog ↗](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/). Given 3 attempts with default settings:

| Attempt | Upper Bound                   | Actual Delay     |
| ------- | ----------------------------- | ---------------- |
| 1       | min(2^1 \* 100, 3000) = 200ms | random(0, 200ms) |
| 2       | min(2^2 \* 100, 3000) = 400ms | random(0, 400ms) |
| 3       | (no retry — final attempt)    | —                |

With `maxAttempts: 5` and `baseDelayMs: 500`:

| Attempt | Upper Bound                   | Actual Delay      |
| ------- | ----------------------------- | ----------------- |
| 1       | min(2 \* 500, 3000) = 1000ms  | random(0, 1000ms) |
| 2       | min(4 \* 500, 3000) = 2000ms  | random(0, 2000ms) |
| 3       | min(8 \* 500, 3000) = 3000ms  | random(0, 3000ms) |
| 4       | min(16 \* 500, 3000) = 3000ms | random(0, 3000ms) |
| 5       | (no retry — final attempt)    | —                 |

### MCP server retries

When adding an MCP server, you can configure retry options for connection and reconnection attempts:

* [  JavaScript ](#tab-panel-6311)
* [  TypeScript ](#tab-panel-6312)

JavaScript

```
await this.addMcpServer("github", "https://mcp.github.com", {  retry: { maxAttempts: 5, baseDelayMs: 1000, maxDelayMs: 10000 },});
```

TypeScript

```
await this.addMcpServer("github", "https://mcp.github.com", {  retry: { maxAttempts: 5, baseDelayMs: 1000, maxDelayMs: 10000 },});
```

These options are persisted and used when:

* Restoring server connections after hibernation
* Establishing connections after OAuth completion

Default: 3 attempts, 500ms base delay, 5s max delay.

## Patterns

### Retry with logging

* [  JavaScript ](#tab-panel-6319)
* [  TypeScript ](#tab-panel-6320)

JavaScript

```
class MyAgent extends Agent {  async resilientTask(payload) {    try {      const result = await this.retry(        async (attempt) => {          if (attempt > 1) {            console.log(`Retrying ${payload.url} (attempt ${attempt})...`);          }          const res = await fetch(payload.url);          if (!res.ok) throw new Error(`HTTP ${res.status}`);          return res.json();        },        { maxAttempts: 5 },      );      console.log("Success:", result);    } catch (e) {      console.error("All retries failed:", e);    }  }}
```

TypeScript

```
class MyAgent extends Agent {  async resilientTask(payload: { url: string }) {    try {      const result = await this.retry(        async (attempt) => {          if (attempt > 1) {            console.log(`Retrying ${payload.url} (attempt ${attempt})...`);          }          const res = await fetch(payload.url);          if (!res.ok) throw new Error(`HTTP ${res.status}`);          return res.json();        },        { maxAttempts: 5 },      );      console.log("Success:", result);    } catch (e) {      console.error("All retries failed:", e);    }  }}
```

### Retry with fallback

* [  JavaScript ](#tab-panel-6317)
* [  TypeScript ](#tab-panel-6318)

JavaScript

```
class MyAgent extends Agent {  async fetchData() {    try {      return await this.retry(        () => fetch("https://primary-api.example.com/data"),        { maxAttempts: 3, baseDelayMs: 200 },      );    } catch {      // Primary failed, try fallback      return await this.retry(        () => fetch("https://fallback-api.example.com/data"),        { maxAttempts: 2 },      );    }  }}
```

TypeScript

```
class MyAgent extends Agent {  async fetchData() {    try {      return await this.retry(        () => fetch("https://primary-api.example.com/data"),        { maxAttempts: 3, baseDelayMs: 200 },      );    } catch {      // Primary failed, try fallback      return await this.retry(        () => fetch("https://fallback-api.example.com/data"),        { maxAttempts: 2 },      );    }  }}
```

### Combining retries with scheduling

For operations that might take a long time to recover (minutes or hours), combine `this.retry()` for immediate retries with `this.schedule()` for delayed retries:

* [  JavaScript ](#tab-panel-6323)
* [  TypeScript ](#tab-panel-6324)

JavaScript

```
class MyAgent extends Agent {  async syncData(payload) {    const attempt = payload.attempt ?? 1;
    try {      // Immediate retries for transient failures (seconds)      await this.retry(() => this.fetchAndProcess(payload.source), {        maxAttempts: 3,        baseDelayMs: 1000,      });    } catch (e) {      if (attempt >= 5) {        console.error("Giving up after 5 scheduled attempts");        return;      }
      // Schedule a retry in 5 minutes for longer outages      const delaySeconds = 300 * attempt;      await this.schedule(delaySeconds, "syncData", {        source: payload.source,        attempt: attempt + 1,      });      console.log(`Scheduled retry ${attempt + 1} in ${delaySeconds}s`);    }  }}
```

TypeScript

```
class MyAgent extends Agent {  async syncData(payload: { source: string; attempt?: number }) {    const attempt = payload.attempt ?? 1;
    try {      // Immediate retries for transient failures (seconds)      await this.retry(() => this.fetchAndProcess(payload.source), {        maxAttempts: 3,        baseDelayMs: 1000,      });    } catch (e) {      if (attempt >= 5) {        console.error("Giving up after 5 scheduled attempts");        return;      }
      // Schedule a retry in 5 minutes for longer outages      const delaySeconds = 300 * attempt;      await this.schedule(delaySeconds, "syncData", {        source: payload.source,        attempt: attempt + 1,      });      console.log(`Scheduled retry ${attempt + 1} in ${delaySeconds}s`);    }  }}
```

## Limitations

* **No dead-letter queue.** If a queued or scheduled task fails all retry attempts, it is removed. Implement your own persistence if you need to track failed tasks.
* **Retry delays block the agent.** During the backoff delay, the Durable Object is awake but idle. For short delays (under 3 seconds) this is fine. For longer recovery times, use `this.schedule()` instead.
* **Queue retries are head-of-line blocking.** Queue items are processed sequentially. If one item is being retried with long delays, it blocks all subsequent items. If you need independent retry behavior, use `this.retry()` inside the callback rather than per-task retry options on `queue()`.
* **No circuit breaker.** The retry system does not track failure rates across calls. If a service is persistently down, each task will exhaust its retry budget independently.
* **`shouldRetry` is only available on `this.retry()`.** The `shouldRetry` predicate cannot be used with `schedule()` or `queue()` because functions cannot be serialized to the database. For scheduled/queued tasks, handle non-retryable errors inside the callback itself.

## Next steps

[ Schedule tasks ](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) Schedule tasks for future execution. 

[ Queue tasks ](https://developers.cloudflare.com/agents/runtime/execution/queue-tasks/) Background task queue for immediate processing. 

[ Run Workflows ](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/) Durable multi-step processing with automatic retries.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/execution/retries/#page","headline":"Retries · Cloudflare Agents docs","description":"Retry failed operations with exponential backoff and jitter using the built-in retry system in the Agents SDK.","url":"https://developers.cloudflare.com/agents/runtime/execution/retries/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/execution/","name":"Execution"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/execution/retries/","name":"Retries"}}]}
```

---

---
title: Run Workflows
description: Integrate Cloudflare Workflows with Agents for durable, multi-step background processing and failure recovery.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Run Workflows

Integrate [Cloudflare Workflows](https://developers.cloudflare.com/workflows/) with Agents for durable, multi-step background processing while Agents handle real-time communication.

Agents vs. Workflows

Agents excel at real-time communication and state management. Workflows excel at durable execution with automatic retries, failure recovery, and waiting for external events.

Use Agents alone for chat, messaging, and quick API calls. Use Agent + Workflow for long-running tasks (over 30 seconds), multi-step pipelines, and human approval flows.

## Quick start

### 1\. Define a Workflow

Extend `AgentWorkflow` for typed access to the originating Agent:

* [  JavaScript ](#tab-panel-6349)
* [  TypeScript ](#tab-panel-6350)

JavaScript

```
import { AgentWorkflow } from "agents/workflows";
export class ProcessingWorkflow extends AgentWorkflow {  async run(event, step) {    const params = event.payload;
    const result = await step.do("process-data", async () => {      return processData(params.data);    });
    // Non-durable: progress reporting (may repeat on retry)    await this.reportProgress({      step: "process",      status: "complete",      percent: 0.5,    });
    // Broadcast to connected WebSocket clients    this.broadcastToClients({ type: "update", taskId: params.taskId });
    await step.do("save-results", async () => {      // Call Agent methods via RPC      await this.agent.saveResult(params.taskId, result);    });
    // Durable: idempotent, won't repeat on retry    await step.reportComplete(result);    return result;  }}
```

TypeScript

```
import { AgentWorkflow } from "agents/workflows";import type { AgentWorkflowEvent, AgentWorkflowStep } from "agents/workflows";import type { MyAgent } from "./agent";
type TaskParams = { taskId: string; data: string };
export class ProcessingWorkflow extends AgentWorkflow<MyAgent, TaskParams> {  async run(event: AgentWorkflowEvent<TaskParams>, step: AgentWorkflowStep) {    const params = event.payload;
    const result = await step.do("process-data", async () => {      return processData(params.data);    });
    // Non-durable: progress reporting (may repeat on retry)    await this.reportProgress({      step: "process",      status: "complete",      percent: 0.5,    });
    // Broadcast to connected WebSocket clients    this.broadcastToClients({ type: "update", taskId: params.taskId });
    await step.do("save-results", async () => {      // Call Agent methods via RPC      await this.agent.saveResult(params.taskId, result);    });
    // Durable: idempotent, won't repeat on retry    await step.reportComplete(result);    return result;  }}
```

### 2\. Start a Workflow from an Agent

Use `runWorkflow()` to start and track workflows:

* [  JavaScript ](#tab-panel-6351)
* [  TypeScript ](#tab-panel-6352)

JavaScript

```
import { Agent } from "agents";
export class MyAgent extends Agent {  async startTask(taskId, data) {    const instanceId = await this.runWorkflow("PROCESSING_WORKFLOW", {      taskId,      data,    });    return { instanceId };  }
  async onWorkflowProgress(workflowName, instanceId, progress) {    this.broadcast(JSON.stringify({ type: "workflow-progress", progress }));  }
  async onWorkflowComplete(workflowName, instanceId, result) {    console.log(`Workflow completed:`, result);  }
  async saveResult(taskId, result) {    this      .sql`INSERT INTO results (task_id, data) VALUES (${taskId}, ${JSON.stringify(result)})`;  }}
```

TypeScript

```
import { Agent } from "agents";
export class MyAgent extends Agent {  async startTask(taskId: string, data: string) {    const instanceId = await this.runWorkflow("PROCESSING_WORKFLOW", {      taskId,      data,    });    return { instanceId };  }
  async onWorkflowProgress(    workflowName: string,    instanceId: string,    progress: unknown,  ) {    this.broadcast(JSON.stringify({ type: "workflow-progress", progress }));  }
  async onWorkflowComplete(    workflowName: string,    instanceId: string,    result?: unknown,  ) {    console.log(`Workflow completed:`, result);  }
  async saveResult(taskId: string, result: unknown) {    this      .sql`INSERT INTO results (task_id, data) VALUES (${taskId}, ${JSON.stringify(result)})`;  }}
```

### 3\. Configure Wrangler

* [  wrangler.jsonc ](#tab-panel-6325)
* [  wrangler.toml ](#tab-panel-6326)

JSONC

```
{  "name": "my-app",  "main": "src/index.ts",  // Set this to today's date  "compatibility_date": "2026-06-30",  "durable_objects": {    "bindings": [{ "name": "MY_AGENT", "class_name": "MyAgent" }],  },  "workflows": [    {      "name": "processing-workflow",      "binding": "PROCESSING_WORKFLOW",      "class_name": "ProcessingWorkflow",    },  ],  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyAgent"] }],}
```

TOML

```
name = "my-app"main = "src/index.ts"# Set this to today's datecompatibility_date = "2026-06-30"
[[durable_objects.bindings]]name = "MY_AGENT"class_name = "MyAgent"
[[workflows]]name = "processing-workflow"binding = "PROCESSING_WORKFLOW"class_name = "ProcessingWorkflow"
[[migrations]]tag = "v1"new_sqlite_classes = [ "MyAgent" ]
```

## AgentWorkflow class

Base class for Workflows that integrate with Agents.

### Type parameters

| Parameter    | Description                                               |
| ------------ | --------------------------------------------------------- |
| AgentType    | The Agent class type for typed RPC                        |
| Params       | Parameters passed to the workflow                         |
| ProgressType | Type for progress reporting (defaults to DefaultProgress) |
| Env          | Environment type (defaults to Cloudflare.Env)             |

### Properties

| Property     | Type   | Description                                                                                                                                                                                       |
| ------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| agent        | Stub   | Typed stub for calling Agent methods. For workflows started from a sub-agent, this is an RPC-only stub back to the originating facet; use sub-agent routing for HTTP or WebSocket fetch() traffic |
| instanceId   | string | The workflow instance ID                                                                                                                                                                          |
| workflowName | string | The workflow binding name                                                                                                                                                                         |
| env          | Env    | Environment bindings                                                                                                                                                                              |

### Instance methods (non-durable)

These methods may repeat on retry. Use for lightweight, frequent updates.

#### reportProgress(progress)

Report progress to the Agent. Triggers `onWorkflowProgress` callback.

* [  JavaScript ](#tab-panel-6329)
* [  TypeScript ](#tab-panel-6330)

JavaScript

```
await this.reportProgress({  step: "processing",  status: "running",  percent: 0.5,});
```

TypeScript

```
await this.reportProgress({  step: "processing",  status: "running",  percent: 0.5,});
```

#### broadcastToClients(message)

Broadcast a message to all WebSocket clients connected to the Agent.

* [  JavaScript ](#tab-panel-6327)
* [  TypeScript ](#tab-panel-6328)

JavaScript

```
this.broadcastToClients({ type: "update", data: result });
```

TypeScript

```
this.broadcastToClients({ type: "update", data: result });
```

#### waitForApproval(step, options?)

Wait for an approval event. Throws `WorkflowRejectedError` if rejected.

* [  JavaScript ](#tab-panel-6331)
* [  TypeScript ](#tab-panel-6332)

JavaScript

```
const approval = await this.waitForApproval(step, {  timeout: "7 days",});
```

TypeScript

```
const approval = await this.waitForApproval<{ approvedBy: string }>(step, {  timeout: "7 days",});
```

### Step methods (durable)

These methods are idempotent and will not repeat on retry. Use for state changes that must persist.

| Method                        | Description                                    |
| ----------------------------- | ---------------------------------------------- |
| step.reportComplete(result?)  | Report successful completion                   |
| step.reportError(error)       | Report an error                                |
| step.sendEvent(event)         | Send a custom event to the Agent               |
| step.updateAgentState(state)  | Replace Agent state (broadcasts to clients)    |
| step.mergeAgentState(partial) | Merge into Agent state (broadcasts to clients) |
| step.resetAgentState()        | Reset Agent state to initialState              |

### DefaultProgress type

TypeScript

```
type DefaultProgress = {  step?: string;  status?: "pending" | "running" | "complete" | "error";  message?: string;  percent?: number;  [key: string]: unknown;};
```

## Agent workflow methods

Methods available on the `Agent` class for Workflow management.

### runWorkflow(workflowName, params, options?)

Start a workflow instance and track it in the Agent database.

**Parameters:**

| Parameter            | Type   | Description                                                                                                           |
| -------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- |
| workflowName         | string | Workflow binding name from env                                                                                        |
| params               | object | Parameters to pass to the workflow                                                                                    |
| options.id           | string | Custom workflow ID (auto-generated if not provided)                                                                   |
| options.metadata     | object | Metadata stored for querying (not passed to workflow)                                                                 |
| options.agentBinding | string | Agent binding name (auto-detected if not provided). When called from a sub-agent, this is the root Agent binding name |

**Returns:** `Promise<string>` \- Workflow instance ID

* [  JavaScript ](#tab-panel-6333)
* [  TypeScript ](#tab-panel-6334)

JavaScript

```
const instanceId = await this.runWorkflow(  "MY_WORKFLOW",  { taskId: "123" },  {    metadata: { userId: "user-456", priority: "high" },  },);
```

TypeScript

```
const instanceId = await this.runWorkflow(  "MY_WORKFLOW",  { taskId: "123" },  {    metadata: { userId: "user-456", priority: "high" },  },);
```

#### Starting workflows from sub-agents

Sub-agents can call `this.runWorkflow()` directly. The workflow is tracked in the originating sub-agent's SQLite database, and `this.agent` inside `AgentWorkflow` routes back to that same sub-agent for RPC calls, callbacks, state updates, and broadcasts.

Parent agents do not automatically list or control workflows that a sub-agent starts. `SubAgentStub<T>` only exposes user-defined methods, not inherited `Agent` methods such as `approveWorkflow()` or `getWorkflow()`. To control a child-started workflow from the parent, define small wrapper methods on the child and call those wrappers through the sub-agent stub.

* [  JavaScript ](#tab-panel-6359)
* [  TypeScript ](#tab-panel-6360)

JavaScript

```
export class ParentAgent extends Agent {  async startChildWorkflow(childName, task) {    const child = await this.subAgent(ChildAgent, childName);    return child.startWorkflow(task);  }
  async approveChildWorkflow(childName, workflowId) {    const child = await this.subAgent(ChildAgent, childName);    return child.approveChildWorkflow(workflowId);  }}
export class ChildAgent extends Agent {  async startWorkflow(task) {    return this.runWorkflow("CHILD_WORKFLOW", { task });  }
  async approveChildWorkflow(workflowId) {    return this.approveWorkflow(workflowId);  }
  async getChildWorkflow(workflowId) {    return this.getWorkflow(workflowId);  }}
```

TypeScript

```
export class ParentAgent extends Agent {  async startChildWorkflow(childName: string, task: string) {    const child = await this.subAgent(ChildAgent, childName);    return child.startWorkflow(task);  }
  async approveChildWorkflow(childName: string, workflowId: string) {    const child = await this.subAgent(ChildAgent, childName);    return child.approveChildWorkflow(workflowId);  }}
export class ChildAgent extends Agent {  async startWorkflow(task: string) {    return this.runWorkflow("CHILD_WORKFLOW", { task });  }
  async approveChildWorkflow(workflowId: string) {    return this.approveWorkflow(workflowId);  }
  async getChildWorkflow(workflowId: string) {    return this.getWorkflow(workflowId);  }}
```

For sub-agent origins, `AgentWorkflow.agent` is an RPC-only stub. Use it to call Agent methods, but use `routeSubAgentRequest()` or the `/agents/{parent}/{name}/sub/{child}/{name}` URL shape for external HTTP or WebSocket routing instead of `this.agent.fetch()`.

#### Routing constraints

Because the originating identity is persisted durably in the workflow params and replayed on every callback, a few constraints apply to all workflows (sub-agent and top-level alike):

* **Callbacks resolve the Agent by name.** The runtime re-resolves the originating Agent with `getAgentByName(...)`. If you addressed the Agent by a raw Durable Object ID (`idFromString` / `get(id)`) instead of by name, callbacks land on a different instance. Start workflows from name-addressed Agents.
* **Class names must survive bundling.** The originating path is keyed by `constructor.name`. Configure your bundler to preserve class names (esbuild `keepNames: true`) so progress, completion, and `this.agent` RPC can be routed back to the right facet.
* **`agentBinding` is the root binding.** When you pass `options.agentBinding` from a sub-agent, use the **root** Agent's Durable Object binding name, not a child binding.

### sendWorkflowEvent(workflowName, instanceId, event)

Send an event to a running workflow.

* [  JavaScript ](#tab-panel-6335)
* [  TypeScript ](#tab-panel-6336)

JavaScript

```
await this.sendWorkflowEvent("MY_WORKFLOW", instanceId, {  type: "custom-event",  payload: { action: "proceed" },});
```

TypeScript

```
await this.sendWorkflowEvent("MY_WORKFLOW", instanceId, {  type: "custom-event",  payload: { action: "proceed" },});
```

### getWorkflowStatus(workflowName, instanceId)

Get the status of a workflow and update the tracking record.

* [  JavaScript ](#tab-panel-6337)
* [  TypeScript ](#tab-panel-6338)

JavaScript

```
const status = await this.getWorkflowStatus("MY_WORKFLOW", instanceId);// { status: 'running', output: null, error: null }
```

TypeScript

```
const status = await this.getWorkflowStatus("MY_WORKFLOW", instanceId);// { status: 'running', output: null, error: null }
```

### getWorkflow(instanceId)

Get a tracked workflow by ID.

* [  JavaScript ](#tab-panel-6339)
* [  TypeScript ](#tab-panel-6340)

JavaScript

```
const workflow = this.getWorkflow(instanceId);// { instanceId, workflowName, status, metadata, error, createdAt, ... }
```

TypeScript

```
const workflow = this.getWorkflow(instanceId);// { instanceId, workflowName, status, metadata, error, createdAt, ... }
```

### getWorkflows(criteria?)

Query tracked workflows with cursor-based pagination. Returns a `WorkflowPage` with workflows, total count, and cursor for the next page.

* [  JavaScript ](#tab-panel-6363)
* [  TypeScript ](#tab-panel-6364)

JavaScript

```
// Get running workflows (default limit is 50, max is 100)const { workflows, total } = this.getWorkflows({ status: "running" });
// Filter by metadataconst { workflows: userWorkflows } = this.getWorkflows({  metadata: { userId: "user-456" },});
// Pagination with cursorconst page1 = this.getWorkflows({  status: ["complete", "errored"],  limit: 20,  orderBy: "desc",});
console.log(`Showing ${page1.workflows.length} of ${page1.total} workflows`);
// Get next page using cursorif (page1.nextCursor) {  const page2 = this.getWorkflows({    status: ["complete", "errored"],    limit: 20,    orderBy: "desc",    cursor: page1.nextCursor,  });}
```

TypeScript

```
// Get running workflows (default limit is 50, max is 100)const { workflows, total } = this.getWorkflows({ status: "running" });
// Filter by metadataconst { workflows: userWorkflows } = this.getWorkflows({  metadata: { userId: "user-456" },});
// Pagination with cursorconst page1 = this.getWorkflows({  status: ["complete", "errored"],  limit: 20,  orderBy: "desc",});
console.log(`Showing ${page1.workflows.length} of ${page1.total} workflows`);
// Get next page using cursorif (page1.nextCursor) {  const page2 = this.getWorkflows({    status: ["complete", "errored"],    limit: 20,    orderBy: "desc",    cursor: page1.nextCursor,  });}
```

The `WorkflowPage` type:

TypeScript

```
type WorkflowPage = {  workflows: WorkflowInfo[];  total: number; // Total matching workflows  nextCursor: string | null; // null when no more pages};
```

### deleteWorkflow(instanceId)

Delete a single workflow instance tracking record. Returns `true` if deleted, `false` if not found.

### deleteWorkflows(criteria?)

Delete workflow instance tracking records matching criteria.

* [  JavaScript ](#tab-panel-6347)
* [  TypeScript ](#tab-panel-6348)

JavaScript

```
// Delete completed workflow instances older than 7 daysthis.deleteWorkflows({  status: "complete",  createdBefore: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),});
// Delete all errored and terminated workflowsthis.deleteWorkflows({  status: ["errored", "terminated"],});
```

TypeScript

```
// Delete completed workflow instances older than 7 daysthis.deleteWorkflows({  status: "complete",  createdBefore: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),});
// Delete all errored and terminated workflowsthis.deleteWorkflows({  status: ["errored", "terminated"],});
```

### terminateWorkflow(instanceId)

Terminate a running workflow immediately. Sets status to `"terminated"`.

* [  JavaScript ](#tab-panel-6341)
* [  TypeScript ](#tab-panel-6342)

JavaScript

```
await this.terminateWorkflow(instanceId);
```

TypeScript

```
await this.terminateWorkflow(instanceId);
```

Note

`terminate()` is not yet supported in local development with `wrangler dev`. It works when deployed to Cloudflare.

### pauseWorkflow(instanceId)

Pause a running workflow. The workflow can be resumed later with `resumeWorkflow()`.

* [  JavaScript ](#tab-panel-6343)
* [  TypeScript ](#tab-panel-6344)

JavaScript

```
await this.pauseWorkflow(instanceId);
```

TypeScript

```
await this.pauseWorkflow(instanceId);
```

Note

`pause()` is not yet supported in local development with `wrangler dev`. It works when deployed to Cloudflare.

### resumeWorkflow(instanceId)

Resume a paused workflow.

* [  JavaScript ](#tab-panel-6345)
* [  TypeScript ](#tab-panel-6346)

JavaScript

```
await this.resumeWorkflow(instanceId);
```

TypeScript

```
await this.resumeWorkflow(instanceId);
```

Note

`resume()` is not yet supported in local development with `wrangler dev`. It works when deployed to Cloudflare.

### restartWorkflow(instanceId, options?)

Restart a workflow instance from the beginning with the same ID.

* [  JavaScript ](#tab-panel-6353)
* [  TypeScript ](#tab-panel-6354)

JavaScript

```
// Reset tracking (default) - clears timestamps and error fieldsawait this.restartWorkflow(instanceId);
// Preserve original timestampsawait this.restartWorkflow(instanceId, { resetTracking: false });
```

TypeScript

```
// Reset tracking (default) - clears timestamps and error fieldsawait this.restartWorkflow(instanceId);
// Preserve original timestampsawait this.restartWorkflow(instanceId, { resetTracking: false });
```

Note

`restart()` is not yet supported in local development with `wrangler dev`. It works when deployed to Cloudflare.

### approveWorkflow(instanceId, options?)

Approve a waiting workflow. Use with `waitForApproval()` in the workflow.

* [  JavaScript ](#tab-panel-6357)
* [  TypeScript ](#tab-panel-6358)

JavaScript

```
await this.approveWorkflow(instanceId, {  reason: "Approved by admin",  metadata: { approvedBy: userId },});
```

TypeScript

```
await this.approveWorkflow(instanceId, {  reason: "Approved by admin",  metadata: { approvedBy: userId },});
```

### rejectWorkflow(instanceId, options?)

Reject a waiting workflow. Causes `waitForApproval()` to throw `WorkflowRejectedError`.

* [  JavaScript ](#tab-panel-6355)
* [  TypeScript ](#tab-panel-6356)

JavaScript

```
await this.rejectWorkflow(instanceId, { reason: "Request denied" });
```

TypeScript

```
await this.rejectWorkflow(instanceId, { reason: "Request denied" });
```

### migrateWorkflowBinding(oldName, newName)

Migrate tracked workflows after renaming a workflow binding.

* [  JavaScript ](#tab-panel-6361)
* [  TypeScript ](#tab-panel-6362)

JavaScript

```
class MyAgent extends Agent {  async onStart() {    this.migrateWorkflowBinding("OLD_WORKFLOW", "NEW_WORKFLOW");  }}
```

TypeScript

```
class MyAgent extends Agent {  async onStart() {    this.migrateWorkflowBinding("OLD_WORKFLOW", "NEW_WORKFLOW");  }}
```

## Lifecycle callbacks

Override these methods in your Agent to handle workflow events:

| Callback           | Parameters                         | Description                           |
| ------------------ | ---------------------------------- | ------------------------------------- |
| onWorkflowProgress | workflowName, instanceId, progress | Called when workflow reports progress |
| onWorkflowComplete | workflowName, instanceId, result?  | Called when workflow completes        |
| onWorkflowError    | workflowName, instanceId, error    | Called when workflow errors           |
| onWorkflowEvent    | workflowName, instanceId, event    | Called when workflow sends an event   |
| onWorkflowCallback | callback: WorkflowCallback         | Called for all callback types         |

* [  JavaScript ](#tab-panel-6365)
* [  TypeScript ](#tab-panel-6366)

JavaScript

```
class MyAgent extends Agent {  async onWorkflowProgress(workflowName, instanceId, progress) {    this.broadcast(      JSON.stringify({ type: "progress", workflowName, instanceId, progress }),    );  }
  async onWorkflowComplete(workflowName, instanceId, result) {    console.log(`${workflowName}/${instanceId} completed`);  }
  async onWorkflowError(workflowName, instanceId, error) {    console.error(`${workflowName}/${instanceId} failed:`, error);  }}
```

TypeScript

```
class MyAgent extends Agent {  async onWorkflowProgress(    workflowName: string,    instanceId: string,    progress: unknown,  ) {    this.broadcast(      JSON.stringify({ type: "progress", workflowName, instanceId, progress }),    );  }
  async onWorkflowComplete(    workflowName: string,    instanceId: string,    result?: unknown,  ) {    console.log(`${workflowName}/${instanceId} completed`);  }
  async onWorkflowError(    workflowName: string,    instanceId: string,    error: string,  ) {    console.error(`${workflowName}/${instanceId} failed:`, error);  }}
```

## Workflow tracking

Workflows started with `runWorkflow()` are automatically tracked in the originating Agent's internal database. You can query, filter, and manage workflows using the methods described above (`getWorkflow()`, `getWorkflows()`, `deleteWorkflow()`, etc.).

Sub-agent scoping

`getWorkflows()` and `getWorkflowById()` only see workflows tracked in **this** Agent's storage. If a sub-agent starts the workflow, the row lives in that sub-agent's own `cf_agents_workflows` table, not in the parent's. To build a combined view, expose a wrapper method on each child (for example, `listMyWorkflows()`) and aggregate the results across your sub-agents yourself.

### Status values

| Status     | Description           |
| ---------- | --------------------- |
| queued     | Waiting to start      |
| running    | Currently executing   |
| paused     | Paused by user        |
| waiting    | Waiting for event     |
| complete   | Finished successfully |
| errored    | Failed with error     |
| terminated | Manually terminated   |

Use the `metadata` option in `runWorkflow()` to store queryable information (like user IDs or task types) that you can filter on later with `getWorkflows()`.

## Examples

### Human-in-the-loop approval

* [  JavaScript ](#tab-panel-6377)
* [  TypeScript ](#tab-panel-6378)

JavaScript

```
import { AgentWorkflow } from "agents/workflows";
export class ApprovalWorkflow extends AgentWorkflow {  async run(event, step) {    const request = await step.do("prepare", async () => {      return { ...event.payload, preparedAt: Date.now() };    });
    await this.reportProgress({      step: "approval",      status: "pending",      message: "Awaiting approval",    });
    // Throws WorkflowRejectedError if rejected    const approval = await this.waitForApproval(step, {      timeout: "7 days",    });
    console.log("Approved by:", approval?.approvedBy);
    const result = await step.do("execute", async () => {      return executeRequest(request);    });
    await step.reportComplete(result);    return result;  }}
class MyAgent extends Agent {  async handleApproval(instanceId, userId) {    await this.approveWorkflow(instanceId, {      reason: "Approved by admin",      metadata: { approvedBy: userId },    });  }
  async handleRejection(instanceId, reason) {    await this.rejectWorkflow(instanceId, { reason });  }}
```

TypeScript

```
import { AgentWorkflow } from "agents/workflows";import type { AgentWorkflowEvent, AgentWorkflowStep } from "agents/workflows";
export class ApprovalWorkflow extends AgentWorkflow<MyAgent, RequestParams> {  async run(event: AgentWorkflowEvent<RequestParams>, step: AgentWorkflowStep) {    const request = await step.do("prepare", async () => {      return { ...event.payload, preparedAt: Date.now() };    });
    await this.reportProgress({      step: "approval",      status: "pending",      message: "Awaiting approval",    });
    // Throws WorkflowRejectedError if rejected    const approval = await this.waitForApproval<{ approvedBy: string }>(step, {      timeout: "7 days",    });
    console.log("Approved by:", approval?.approvedBy);
    const result = await step.do("execute", async () => {      return executeRequest(request);    });
    await step.reportComplete(result);    return result;  }}
class MyAgent extends Agent {  async handleApproval(instanceId: string, userId: string) {    await this.approveWorkflow(instanceId, {      reason: "Approved by admin",      metadata: { approvedBy: userId },    });  }
  async handleRejection(instanceId: string, reason: string) {    await this.rejectWorkflow(instanceId, { reason });  }}
```

### Retry with backoff

* [  JavaScript ](#tab-panel-6371)
* [  TypeScript ](#tab-panel-6372)

JavaScript

```
import { AgentWorkflow } from "agents/workflows";
export class ResilientWorkflow extends AgentWorkflow {  async run(event, step) {    const result = await step.do(      "call-api",      {        retries: { limit: 5, delay: "10 seconds", backoff: "exponential" },        timeout: "5 minutes",      },      async () => {        const response = await fetch("https://api.example.com/process", {          method: "POST",          body: JSON.stringify(event.payload),        });        if (!response.ok) throw new Error(`API error: ${response.status}`);        return response.json();      },    );
    await step.reportComplete(result);    return result;  }}
```

TypeScript

```
import { AgentWorkflow } from "agents/workflows";import type { AgentWorkflowEvent, AgentWorkflowStep } from "agents/workflows";
export class ResilientWorkflow extends AgentWorkflow<MyAgent, TaskParams> {  async run(event: AgentWorkflowEvent<TaskParams>, step: AgentWorkflowStep) {    const result = await step.do(      "call-api",      {        retries: { limit: 5, delay: "10 seconds", backoff: "exponential" },        timeout: "5 minutes",      },      async () => {        const response = await fetch("https://api.example.com/process", {          method: "POST",          body: JSON.stringify(event.payload),        });        if (!response.ok) throw new Error(`API error: ${response.status}`);        return response.json();      },    );
    await step.reportComplete(result);    return result;  }}
```

### State synchronization

Workflows can update Agent state durably via `step`, which automatically broadcasts to all connected clients:

* [  JavaScript ](#tab-panel-6375)
* [  TypeScript ](#tab-panel-6376)

JavaScript

```
import { AgentWorkflow } from "agents/workflows";
export class StatefulWorkflow extends AgentWorkflow {  async run(event, step) {    // Replace entire state (durable, broadcasts to clients)    await step.updateAgentState({      currentTask: {        id: event.payload.taskId,        status: "processing",        startedAt: Date.now(),      },    });
    const result = await step.do("process", async () =>      processTask(event.payload),    );
    // Merge partial state (durable, keeps existing fields)    await step.mergeAgentState({      currentTask: { status: "complete", result, completedAt: Date.now() },    });
    await step.reportComplete(result);    return result;  }}
```

TypeScript

```
import { AgentWorkflow } from "agents/workflows";import type { AgentWorkflowEvent, AgentWorkflowStep } from "agents/workflows";
export class StatefulWorkflow extends AgentWorkflow<MyAgent, TaskParams> {  async run(event: AgentWorkflowEvent<TaskParams>, step: AgentWorkflowStep) {    // Replace entire state (durable, broadcasts to clients)    await step.updateAgentState({      currentTask: {        id: event.payload.taskId,        status: "processing",        startedAt: Date.now(),      },    });
    const result = await step.do("process", async () =>      processTask(event.payload),    );
    // Merge partial state (durable, keeps existing fields)    await step.mergeAgentState({      currentTask: { status: "complete", result, completedAt: Date.now() },    });
    await step.reportComplete(result);    return result;  }}
```

### Custom progress types

Define custom progress types for domain-specific reporting:

* [  JavaScript ](#tab-panel-6379)
* [  TypeScript ](#tab-panel-6380)

JavaScript

```
import { AgentWorkflow } from "agents/workflows";
// Custom progress type for data pipeline
// Workflow with custom progress type (3rd type parameter)export class ETLWorkflow extends AgentWorkflow {  async run(event, step) {    await this.reportProgress({      stage: "extract",      recordsProcessed: 0,      totalRecords: 1000,      currentTable: "users",    });
    // ... processing  }}
// Agent receives typed progressclass MyAgent extends Agent {  async onWorkflowProgress(workflowName, instanceId, progress) {    const p = progress;    console.log(`Stage: ${p.stage}, ${p.recordsProcessed}/${p.totalRecords}`);  }}
```

TypeScript

```
import { AgentWorkflow } from "agents/workflows";import type { AgentWorkflowEvent, AgentWorkflowStep } from "agents/workflows";
// Custom progress type for data pipelinetype PipelineProgress = {  stage: "extract" | "transform" | "load";  recordsProcessed: number;  totalRecords: number;  currentTable?: string;};
// Workflow with custom progress type (3rd type parameter)export class ETLWorkflow extends AgentWorkflow<  MyAgent,  ETLParams,  PipelineProgress> {  async run(event: AgentWorkflowEvent<ETLParams>, step: AgentWorkflowStep) {    await this.reportProgress({      stage: "extract",      recordsProcessed: 0,      totalRecords: 1000,      currentTable: "users",    });
    // ... processing  }}
// Agent receives typed progressclass MyAgent extends Agent {  async onWorkflowProgress(    workflowName: string,    instanceId: string,    progress: unknown,  ) {    const p = progress as PipelineProgress;    console.log(`Stage: ${p.stage}, ${p.recordsProcessed}/${p.totalRecords}`);  }}
```

### Cleanup strategy

The internal `cf_agents_workflows` table can grow unbounded, so implement a retention policy:

* [  JavaScript ](#tab-panel-6373)
* [  TypeScript ](#tab-panel-6374)

JavaScript

```
class MyAgent extends Agent {  // Option 1: Delete on completion  async onWorkflowComplete(workflowName, instanceId, result) {    // Process result first, then delete    this.deleteWorkflow(instanceId);  }
  // Option 2: Scheduled cleanup (keep recent history)  async cleanupOldWorkflows() {    this.deleteWorkflows({      status: ["complete", "errored"],      createdBefore: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),    });  }
  // Option 3: Keep all history for compliance/auditing  // Don't call deleteWorkflows() - query historical data as needed}
```

TypeScript

```
class MyAgent extends Agent {  // Option 1: Delete on completion  async onWorkflowComplete(    workflowName: string,    instanceId: string,    result?: unknown,  ) {    // Process result first, then delete    this.deleteWorkflow(instanceId);  }
  // Option 2: Scheduled cleanup (keep recent history)  async cleanupOldWorkflows() {    this.deleteWorkflows({      status: ["complete", "errored"],      createdBefore: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),    });  }
  // Option 3: Keep all history for compliance/auditing  // Don't call deleteWorkflows() - query historical data as needed}
```

## Bidirectional communication

### Workflow to Agent

* [  JavaScript ](#tab-panel-6369)
* [  TypeScript ](#tab-panel-6370)

JavaScript

```
// Direct RPC call (typed)await this.agent.updateTaskStatus(taskId, "processing");const data = await this.agent.getData(taskId);
// Non-durable callbacks (may repeat on retry, use for frequent updates)await this.reportProgress({ step: "process", percent: 0.5 });this.broadcastToClients({ type: "update", data });
// Durable callbacks via step (idempotent, won't repeat on retry)await step.reportComplete(result);await step.reportError("Something went wrong");await step.sendEvent({ type: "custom", data: {} });
// Durable state synchronization via step (broadcasts to clients)await step.updateAgentState({ status: "processing" });await step.mergeAgentState({ progress: 0.5 });
```

TypeScript

```
// Direct RPC call (typed)await this.agent.updateTaskStatus(taskId, "processing");const data = await this.agent.getData(taskId);
// Non-durable callbacks (may repeat on retry, use for frequent updates)await this.reportProgress({ step: "process", percent: 0.5 });this.broadcastToClients({ type: "update", data });
// Durable callbacks via step (idempotent, won't repeat on retry)await step.reportComplete(result);await step.reportError("Something went wrong");await step.sendEvent({ type: "custom", data: {} });
// Durable state synchronization via step (broadcasts to clients)await step.updateAgentState({ status: "processing" });await step.mergeAgentState({ progress: 0.5 });
```

### Agent to Workflow

* [  JavaScript ](#tab-panel-6367)
* [  TypeScript ](#tab-panel-6368)

JavaScript

```
// Send event to waiting workflowawait this.sendWorkflowEvent("MY_WORKFLOW", instanceId, {  type: "custom-event",  payload: { action: "proceed" },});
// Approve/reject workflows using convenience methodsawait this.approveWorkflow(instanceId, {  reason: "Approved by admin",  metadata: { approvedBy: userId },});
await this.rejectWorkflow(instanceId, { reason: "Request denied" });
```

TypeScript

```
// Send event to waiting workflowawait this.sendWorkflowEvent("MY_WORKFLOW", instanceId, {  type: "custom-event",  payload: { action: "proceed" },});
// Approve/reject workflows using convenience methodsawait this.approveWorkflow(instanceId, {  reason: "Approved by admin",  metadata: { approvedBy: userId },});
await this.rejectWorkflow(instanceId, { reason: "Request denied" });
```

## Best practices

1. **Keep workflows focused** — One workflow per logical task
2. **Use meaningful step names** — Helps with debugging and observability
3. **Report progress regularly** — Keeps users informed
4. **Handle errors gracefully** — Use `reportError()` before throwing
5. **Clean up completed workflows** — Implement a retention policy for the tracking table
6. **Handle workflow binding renames** — Use `migrateWorkflowBinding()` when renaming workflow bindings in `wrangler.jsonc`

## Limitations

| Constraint          | Limit                                                     |
| ------------------- | --------------------------------------------------------- |
| Maximum steps       | 10,000 per workflow (default) / configurable up to 25,000 |
| State size          | 10 MB per workflow                                        |
| Event wait time     | 1 year maximum                                            |
| Step execution time | 30 minutes per step                                       |

Workflows cannot open WebSocket connections directly. Use `broadcastToClients()` to communicate with connected clients through the Agent.

## Related resources

[ Workflows documentation ](https://developers.cloudflare.com/workflows/) Learn about Cloudflare Workflows fundamentals. 

[ Store and sync state ](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) Persist and synchronize agent state. 

[ Schedule tasks ](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) Time-based task execution. 

[ Human-in-the-loop ](https://developers.cloudflare.com/agents/concepts/agentic-patterns/human-in-the-loop/) Approval flows and manual intervention patterns.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/execution/run-workflows/#page","headline":"Run Workflows · Cloudflare Agents docs","description":"Integrate Cloudflare Workflows with Agents for durable, multi-step background processing and failure recovery.","url":"https://developers.cloudflare.com/agents/runtime/execution/run-workflows/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-26","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/execution/","name":"Execution"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/execution/run-workflows/","name":"Run Workflows"}}]}
```

---

---
title: Schedule tasks
description: Schedule delayed, date-based, cron, and interval tasks on Agents with persistent SQLite-backed execution.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Schedule tasks

Schedule tasks to run in the future — whether that is seconds from now, at a specific date/time, or on a recurring cron schedule. Scheduled tasks survive agent restarts and are persisted to SQLite.

Scheduled tasks can do anything a request or message from a user can: make requests, query databases, send emails, read and write state. Scheduled tasks can invoke any regular method on your Agent.

## Overview

The scheduling system supports four modes:

| Mode          | Syntax                             | Use case                  |
| ------------- | ---------------------------------- | ------------------------- |
| **Delayed**   | this.schedule(60, ...)             | Run in 60 seconds         |
| **Scheduled** | this.schedule(new Date(...), ...)  | Run at specific time      |
| **Cron**      | this.schedule("0 8 \* \* \*", ...) | Run on recurring schedule |
| **Interval**  | this.scheduleEvery(30, ...)        | Run every 30 seconds      |

Under the hood, scheduling uses [Durable Object alarms](https://developers.cloudflare.com/durable-objects/api/alarms/) to wake the agent at the right time. Tasks are stored in a SQLite table and executed in order.

## Quick start

* [  JavaScript ](#tab-panel-6401)
* [  TypeScript ](#tab-panel-6402)

JavaScript

```
import { Agent } from "agents";
export class ReminderAgent extends Agent {  async onRequest(request) {    const url = new URL(request.url);
    // Schedule in 30 seconds    await this.schedule(30, "sendReminder", {      message: "Check your email",    });
    // Schedule at specific time    await this.schedule(new Date("2025-02-01T09:00:00Z"), "sendReminder", {      message: "Monthly report due",    });
    // Schedule recurring (every day at 8am)    await this.schedule("0 8 * * *", "dailyDigest", {      userId: url.searchParams.get("userId"),    });
    return new Response("Scheduled!");  }
  async sendReminder(payload) {    console.log(`Reminder: ${payload.message}`);    // Send notification, email, etc.  }
  async dailyDigest(payload) {    console.log(`Sending daily digest to ${payload.userId}`);    // Generate and send digest  }}
```

TypeScript

```
import { Agent } from "agents";
export class ReminderAgent extends Agent {  async onRequest(request: Request) {    const url = new URL(request.url);
    // Schedule in 30 seconds    await this.schedule(30, "sendReminder", {      message: "Check your email",    });
    // Schedule at specific time    await this.schedule(new Date("2025-02-01T09:00:00Z"), "sendReminder", {      message: "Monthly report due",    });
    // Schedule recurring (every day at 8am)    await this.schedule("0 8 * * *", "dailyDigest", {      userId: url.searchParams.get("userId"),    });
    return new Response("Scheduled!");  }
  async sendReminder(payload: { message: string }) {    console.log(`Reminder: ${payload.message}`);    // Send notification, email, etc.  }
  async dailyDigest(payload: { userId: string }) {    console.log(`Sending daily digest to ${payload.userId}`);    // Generate and send digest  }}
```

## Scheduling modes

### Delayed execution

Pass a number to schedule a task to run after a delay in **seconds**:

* [  JavaScript ](#tab-panel-6381)
* [  TypeScript ](#tab-panel-6382)

JavaScript

```
// Run in 10 secondsawait this.schedule(10, "processTask", { taskId: "123" });
// Run in 5 minutes (300 seconds)await this.schedule(300, "sendFollowUp", { email: "user@example.com" });
// Run in 1 hourawait this.schedule(3600, "checkStatus", { orderId: "abc" });
```

TypeScript

```
// Run in 10 secondsawait this.schedule(10, "processTask", { taskId: "123" });
// Run in 5 minutes (300 seconds)await this.schedule(300, "sendFollowUp", { email: "user@example.com" });
// Run in 1 hourawait this.schedule(3600, "checkStatus", { orderId: "abc" });
```

**Use cases:**

* Debouncing rapid events
* Delayed notifications ("You left items in your cart")
* Retry with backoff
* Rate limiting

### Scheduled execution

Pass a `Date` object to schedule a task at a specific time:

* [  JavaScript ](#tab-panel-6385)
* [  TypeScript ](#tab-panel-6386)

JavaScript

```
// Run tomorrow at noonconst tomorrow = new Date();tomorrow.setDate(tomorrow.getDate() + 1);tomorrow.setHours(12, 0, 0, 0);await this.schedule(tomorrow, "sendReminder", { message: "Meeting time!" });
// Run at a specific timestampawait this.schedule(new Date("2025-06-15T14:30:00Z"), "triggerEvent", {  eventId: "conference-2025",});
// Run in 2 hours using Date mathconst twoHoursFromNow = new Date(Date.now() + 2 * 60 * 60 * 1000);await this.schedule(twoHoursFromNow, "checkIn", {});
```

TypeScript

```
// Run tomorrow at noonconst tomorrow = new Date();tomorrow.setDate(tomorrow.getDate() + 1);tomorrow.setHours(12, 0, 0, 0);await this.schedule(tomorrow, "sendReminder", { message: "Meeting time!" });
// Run at a specific timestampawait this.schedule(new Date("2025-06-15T14:30:00Z"), "triggerEvent", {  eventId: "conference-2025",});
// Run in 2 hours using Date mathconst twoHoursFromNow = new Date(Date.now() + 2 * 60 * 60 * 1000);await this.schedule(twoHoursFromNow, "checkIn", {});
```

**Use cases:**

* Appointment reminders
* Deadline notifications
* Scheduled content publishing
* Time-based triggers

### Recurring (cron)

Pass a cron expression string for recurring schedules:

* [  JavaScript ](#tab-panel-6391)
* [  TypeScript ](#tab-panel-6392)

JavaScript

```
// Every day at 8:00 AMawait this.schedule("0 8 * * *", "dailyReport", {});
// Every hourawait this.schedule("0 * * * *", "hourlyCheck", {});
// Every Monday at 9:00 AMawait this.schedule("0 9 * * 1", "weeklySync", {});
// Every 15 minutesawait this.schedule("*/15 * * * *", "pollForUpdates", {});
// First day of every month at midnightawait this.schedule("0 0 1 * *", "monthlyCleanup", {});
```

TypeScript

```
// Every day at 8:00 AMawait this.schedule("0 8 * * *", "dailyReport", {});
// Every hourawait this.schedule("0 * * * *", "hourlyCheck", {});
// Every Monday at 9:00 AMawait this.schedule("0 9 * * 1", "weeklySync", {});
// Every 15 minutesawait this.schedule("*/15 * * * *", "pollForUpdates", {});
// First day of every month at midnightawait this.schedule("0 0 1 * *", "monthlyCleanup", {});
```

**Cron syntax:** `minute hour day month weekday`

| Field        | Values         | Special characters |
| ------------ | -------------- | ------------------ |
| Minute       | 0-59           | \* , \- /          |
| Hour         | 0-23           | \* , \- /          |
| Day of Month | 1-31           | \* , \- /          |
| Month        | 1-12           | \* , \- /          |
| Day of Week  | 0-6 (0=Sunday) | \* , \- /          |

**Common patterns:**

* [  JavaScript ](#tab-panel-6383)
* [  TypeScript ](#tab-panel-6384)

JavaScript

```
"* * * * *"; // Every minute"*/5 * * * *"; // Every 5 minutes"0 * * * *"; // Every hour (on the hour)"0 0 * * *"; // Every day at midnight"0 8 * * 1-5"; // Weekdays at 8am"0 0 * * 0"; // Every Sunday at midnight"0 0 1 * *"; // First of every month
```

TypeScript

```
"* * * * *"; // Every minute"*/5 * * * *"; // Every 5 minutes"0 * * * *"; // Every hour (on the hour)"0 0 * * *"; // Every day at midnight"0 8 * * 1-5"; // Weekdays at 8am"0 0 * * 0"; // Every Sunday at midnight"0 0 1 * *"; // First of every month
```

**Use cases:**

* Daily/weekly reports
* Periodic cleanup jobs
* Polling external services
* Health checks
* Subscription renewals

Cron schedules are idempotent by default — calling `schedule()` with the same cron expression, callback, and payload multiple times returns the existing schedule instead of creating a duplicate. This makes cron schedules safe to set up in `onStart()`.

### Interval

Use `scheduleEvery()` to run a task at fixed intervals (in seconds). Unlike cron, intervals support sub-minute precision and arbitrary durations:

* [  JavaScript ](#tab-panel-6387)
* [  TypeScript ](#tab-panel-6388)

JavaScript

```
// Poll every 30 secondsawait this.scheduleEvery(30, "poll", { source: "api" });
// Health check every 45 secondsawait this.scheduleEvery(45, "healthCheck", {});
// Sync every 90 seconds (1.5 minutes - cannot be expressed in cron)await this.scheduleEvery(90, "syncData", { destination: "warehouse" });
```

TypeScript

```
// Poll every 30 secondsawait this.scheduleEvery(30, "poll", { source: "api" });
// Health check every 45 secondsawait this.scheduleEvery(45, "healthCheck", {});
// Sync every 90 seconds (1.5 minutes - cannot be expressed in cron)await this.scheduleEvery(90, "syncData", { destination: "warehouse" });
```

**Key differences from cron:**

| Feature             | Cron                                  | Interval               |
| ------------------- | ------------------------------------- | ---------------------- |
| Minimum granularity | 1 minute                              | 1 second               |
| Arbitrary intervals | No (must fit cron pattern)            | Yes                    |
| Fixed schedule      | Yes (for example, "every day at 8am") | No (relative to start) |
| Overlap prevention  | No                                    | Yes (built-in)         |

**Idempotency:**

`scheduleEvery()` is idempotent on the combination of callback name, interval, and payload — calling it multiple times with the same arguments does not create duplicate schedules. This makes it safe to call in `onStart()`, which runs on every Durable Object wake:

* [  JavaScript ](#tab-panel-6389)
* [  TypeScript ](#tab-panel-6390)

JavaScript

```
class MyAgent extends Agent {  async onStart() {    // Safe to call on every wake — only one schedule is created    await this.scheduleEvery(30, "poll", { source: "api" });  }}
```

TypeScript

```
class MyAgent extends Agent {  async onStart() {    // Safe to call on every wake — only one schedule is created    await this.scheduleEvery(30, "poll", { source: "api" });  }}
```

A different interval or payload creates a new, independent schedule.

**Overlap prevention:**

If a callback takes longer than the interval, the next execution is skipped (not queued). This prevents runaway resource usage:

* [  JavaScript ](#tab-panel-6395)
* [  TypeScript ](#tab-panel-6396)

JavaScript

```
class PollingAgent extends Agent {  async poll() {    // If this takes 45 seconds and interval is 30 seconds,    // the next poll is skipped (with a warning logged)    const data = await slowExternalApi();    await this.processData(data);  }}
// Set up 30-second intervalawait this.scheduleEvery(30, "poll", {});
```

TypeScript

```
class PollingAgent extends Agent {  async poll() {    // If this takes 45 seconds and interval is 30 seconds,    // the next poll is skipped (with a warning logged)    const data = await slowExternalApi();    await this.processData(data);  }}
// Set up 30-second intervalawait this.scheduleEvery(30, "poll", {});
```

When a skip occurs, you will see a warning in logs:

```
Skipping interval schedule abc123: previous execution still running
```

**Error resilience:**

If the callback throws an error, the interval continues — only that execution fails:

* [  JavaScript ](#tab-panel-6393)
* [  TypeScript ](#tab-panel-6394)

JavaScript

```
class SyncAgent extends Agent {  async syncData() {    // Even if this throws, the interval keeps running    const response = await fetch("https://api.example.com/data");    if (!response.ok) throw new Error("Sync failed");    // ...  }}
```

TypeScript

```
class SyncAgent extends Agent {  async syncData() {    // Even if this throws, the interval keeps running    const response = await fetch("https://api.example.com/data");    if (!response.ok) throw new Error("Sync failed");    // ...  }}
```

**Use cases:**

* Sub-minute polling (every 10, 30, 45 seconds)
* Intervals that do not map to cron (every 90 seconds, every 7 minutes)
* Rate-limited API polling with precise control
* Real-time data synchronization

## Managing scheduled tasks

### Get a schedule

Retrieve a scheduled task by its ID:

* [  JavaScript ](#tab-panel-6397)
* [  TypeScript ](#tab-panel-6398)

JavaScript

```
const schedule = await this.getScheduleById(scheduleId);
if (schedule) {  console.log(    `Task ${schedule.id} will run at ${new Date(schedule.time * 1000)}`,  );  console.log(`Callback: ${schedule.callback}`);  console.log(`Type: ${schedule.type}`); // "scheduled" | "delayed" | "cron" | "interval"} else {  console.log("Schedule not found");}
```

TypeScript

```
const schedule = await this.getScheduleById(scheduleId);
if (schedule) {  console.log(    `Task ${schedule.id} will run at ${new Date(schedule.time * 1000)}`,  );  console.log(`Callback: ${schedule.callback}`);  console.log(`Type: ${schedule.type}`); // "scheduled" | "delayed" | "cron" | "interval"} else {  console.log("Schedule not found");}
```

### List schedules

Query scheduled tasks with optional filters:

* [  JavaScript ](#tab-panel-6407)
* [  TypeScript ](#tab-panel-6408)

JavaScript

```
// Get all scheduled tasksconst allSchedules = await this.listSchedules();
// Get only cron jobsconst cronJobs = await this.listSchedules({ type: "cron" });
// Get tasks in the next hourconst upcoming = await this.listSchedules({  timeRange: {    start: new Date(),    end: new Date(Date.now() + 60 * 60 * 1000),  },});
// Get a specific task by IDconst specific = await this.listSchedules({ id: "abc123" });
// Combine filtersconst upcomingCronJobs = await this.listSchedules({  type: "cron",  timeRange: {    start: new Date(),    end: new Date(Date.now() + 24 * 60 * 60 * 1000),  },});
```

TypeScript

```
// Get all scheduled tasksconst allSchedules = await this.listSchedules();
// Get only cron jobsconst cronJobs = await this.listSchedules({ type: "cron" });
// Get tasks in the next hourconst upcoming = await this.listSchedules({  timeRange: {    start: new Date(),    end: new Date(Date.now() + 60 * 60 * 1000),  },});
// Get a specific task by IDconst specific = await this.listSchedules({ id: "abc123" });
// Combine filtersconst upcomingCronJobs = await this.listSchedules({  type: "cron",  timeRange: {    start: new Date(),    end: new Date(Date.now() + 24 * 60 * 60 * 1000),  },});
```

### Cancel a schedule

Remove a scheduled task before it executes:

* [  JavaScript ](#tab-panel-6399)
* [  TypeScript ](#tab-panel-6400)

JavaScript

```
const cancelled = await this.cancelSchedule(scheduleId);
if (cancelled) {  console.log("Schedule cancelled successfully");} else {  console.log("Schedule not found (may have already executed)");}
```

TypeScript

```
const cancelled = await this.cancelSchedule(scheduleId);
if (cancelled) {  console.log("Schedule cancelled successfully");} else {  console.log("Schedule not found (may have already executed)");}
```

**Example: Cancellable reminders**

* [  JavaScript ](#tab-panel-6421)
* [  TypeScript ](#tab-panel-6422)

JavaScript

```
class ReminderAgent extends Agent {  async setReminder(userId, message, delaySeconds) {    const schedule = await this.schedule(delaySeconds, "sendReminder", {      userId,      message,    });
    // Store the schedule ID so user can cancel later    this.sql`      INSERT INTO user_reminders (user_id, schedule_id, message)      VALUES (${userId}, ${schedule.id}, ${message})    `;
    return schedule.id;  }
  async cancelReminder(scheduleId) {    const cancelled = await this.cancelSchedule(scheduleId);
    if (cancelled) {      this.sql`DELETE FROM user_reminders WHERE schedule_id = ${scheduleId}`;    }
    return cancelled;  }
  async sendReminder(payload) {    // Send the reminder...
    // Clean up the record    this.sql`DELETE FROM user_reminders WHERE user_id = ${payload.userId}`;  }}
```

TypeScript

```
class ReminderAgent extends Agent {  async setReminder(userId: string, message: string, delaySeconds: number) {    const schedule = await this.schedule(delaySeconds, "sendReminder", {      userId,      message,    });
    // Store the schedule ID so user can cancel later    this.sql`      INSERT INTO user_reminders (user_id, schedule_id, message)      VALUES (${userId}, ${schedule.id}, ${message})    `;
    return schedule.id;  }
  async cancelReminder(scheduleId: string) {    const cancelled = await this.cancelSchedule(scheduleId);
    if (cancelled) {      this.sql`DELETE FROM user_reminders WHERE schedule_id = ${scheduleId}`;    }
    return cancelled;  }
  async sendReminder(payload: { userId: string; message: string }) {    // Send the reminder...
    // Clean up the record    this.sql`DELETE FROM user_reminders WHERE user_id = ${payload.userId}`;  }}
```

## The Schedule object

When you create or retrieve a schedule, you get a `Schedule` object:

TypeScript

```
type Schedule<T> = {  id: string; // Unique identifier  callback: string; // Method name to call  payload: T; // Data passed to the callback  time: number; // Unix timestamp (seconds) of next execution} & (  | { type: "scheduled" } // One-time at specific date  | { type: "delayed"; delayInSeconds: number } // One-time after delay  | { type: "cron"; cron: string } // Recurring (cron expression)  | { type: "interval"; intervalSeconds: number } // Recurring (fixed interval));
```

**Example:**

* [  JavaScript ](#tab-panel-6403)
* [  TypeScript ](#tab-panel-6404)

JavaScript

```
const schedule = await this.schedule(60, "myTask", { foo: "bar" });
console.log(schedule);// {//   id: "abc123xyz",//   callback: "myTask",//   payload: { foo: "bar" },//   time: 1706745600,//   type: "delayed",//   delayInSeconds: 60// }
```

TypeScript

```
const schedule = await this.schedule(60, "myTask", { foo: "bar" });
console.log(schedule);// {//   id: "abc123xyz",//   callback: "myTask",//   payload: { foo: "bar" },//   time: 1706745600,//   type: "delayed",//   delayInSeconds: 60// }
```

## Patterns

### Rescheduling from callbacks

For dynamic recurring schedules, schedule the next run from within the callback:

* [  JavaScript ](#tab-panel-6419)
* [  TypeScript ](#tab-panel-6420)

JavaScript

```
class PollingAgent extends Agent {  async startPolling(intervalSeconds) {    await this.schedule(intervalSeconds, "poll", { interval: intervalSeconds });  }
  async poll(payload) {    try {      const data = await fetch("https://api.example.com/updates");      await this.processUpdates(await data.json());    } catch (error) {      console.error("Polling failed:", error);    }
    // Schedule the next poll (regardless of success/failure)    await this.schedule(payload.interval, "poll", payload);  }
  async stopPolling() {    // Cancel all polling schedules    const schedules = await this.listSchedules({ type: "delayed" });    for (const schedule of schedules) {      if (schedule.callback === "poll") {        await this.cancelSchedule(schedule.id);      }    }  }}
```

TypeScript

```
class PollingAgent extends Agent {  async startPolling(intervalSeconds: number) {    await this.schedule(intervalSeconds, "poll", { interval: intervalSeconds });  }
  async poll(payload: { interval: number }) {    try {      const data = await fetch("https://api.example.com/updates");      await this.processUpdates(await data.json());    } catch (error) {      console.error("Polling failed:", error);    }
    // Schedule the next poll (regardless of success/failure)    await this.schedule(payload.interval, "poll", payload);  }
  async stopPolling() {    // Cancel all polling schedules    const schedules = await this.listSchedules({ type: "delayed" });    for (const schedule of schedules) {      if (schedule.callback === "poll") {        await this.cancelSchedule(schedule.id);      }    }  }}
```

### Exponential backoff retry

* [  JavaScript ](#tab-panel-6423)
* [  TypeScript ](#tab-panel-6424)

JavaScript

```
class RetryAgent extends Agent {  async attemptTask(payload) {    try {      await this.doWork(payload.taskId);      console.log(        `Task ${payload.taskId} succeeded on attempt ${payload.attempt}`,      );    } catch (error) {      if (payload.attempt >= payload.maxAttempts) {        console.error(          `Task ${payload.taskId} failed after ${payload.maxAttempts} attempts`,        );        return;      }
      // Exponential backoff: 2^attempt seconds (2s, 4s, 8s, 16s...)      const delaySeconds = Math.pow(2, payload.attempt);
      await this.schedule(delaySeconds, "attemptTask", {        ...payload,        attempt: payload.attempt + 1,      });
      console.log(`Retrying task ${payload.taskId} in ${delaySeconds}s`);    }  }
  async doWork(taskId) {    // Your actual work here  }}
```

TypeScript

```
class RetryAgent extends Agent {  async attemptTask(payload: {    taskId: string;    attempt: number;    maxAttempts: number;  }) {    try {      await this.doWork(payload.taskId);      console.log(        `Task ${payload.taskId} succeeded on attempt ${payload.attempt}`,      );    } catch (error) {      if (payload.attempt >= payload.maxAttempts) {        console.error(          `Task ${payload.taskId} failed after ${payload.maxAttempts} attempts`,        );        return;      }
      // Exponential backoff: 2^attempt seconds (2s, 4s, 8s, 16s...)      const delaySeconds = Math.pow(2, payload.attempt);
      await this.schedule(delaySeconds, "attemptTask", {        ...payload,        attempt: payload.attempt + 1,      });
      console.log(`Retrying task ${payload.taskId} in ${delaySeconds}s`);    }  }
  async doWork(taskId: string) {    // Your actual work here  }}
```

### Self-destructing agents

You can safely call `this.destroy()` from within a scheduled callback:

* [  JavaScript ](#tab-panel-6409)
* [  TypeScript ](#tab-panel-6410)

JavaScript

```
class TemporaryAgent extends Agent {  async onStart() {    // Self-destruct in 24 hours    await this.schedule(24 * 60 * 60, "cleanup", {});  }
  async cleanup() {    // Perform final cleanup    console.log("Agent lifetime expired, cleaning up...");
    // This is safe to call from a scheduled callback    await this.destroy();  }}
```

TypeScript

```
class TemporaryAgent extends Agent {  async onStart() {    // Self-destruct in 24 hours    await this.schedule(24 * 60 * 60, "cleanup", {});  }
  async cleanup() {    // Perform final cleanup    console.log("Agent lifetime expired, cleaning up...");
    // This is safe to call from a scheduled callback    await this.destroy();  }}
```

Note

When `destroy()` is called from within a scheduled task, the Agent SDK defers the destruction to ensure the scheduled callback completes successfully. The Agent instance will be evicted immediately after the callback finishes executing.

## AI-assisted scheduling

The SDK includes utilities for parsing natural language scheduling requests with AI.

### `getSchedulePrompt()`

Returns a system prompt for parsing natural language into scheduling parameters:

* [  JavaScript ](#tab-panel-6425)
* [  TypeScript ](#tab-panel-6426)

JavaScript

```
import { getSchedulePrompt, scheduleSchema } from "agents";import { generateObject } from "ai";import { openai } from "@ai-sdk/openai";
class SmartScheduler extends Agent {  async parseScheduleRequest(userInput) {    const result = await generateObject({      model: openai("gpt-4o"),      system: getSchedulePrompt({ date: new Date() }),      prompt: userInput,      schema: scheduleSchema,    });
    return result.object;  }
  async handleUserRequest(input) {    // Parse: "remind me to call mom tomorrow at 3pm"    const parsed = await this.parseScheduleRequest(input);
    // parsed = {    //   description: "call mom",    //   when: {    //     type: "scheduled",    //     date: "2025-01-30T15:00:00Z"    //   }    // }
    if (parsed.when.type === "scheduled" && parsed.when.date) {      await this.schedule(new Date(parsed.when.date), "sendReminder", {        message: parsed.description,      });    } else if (parsed.when.type === "delayed" && parsed.when.delayInSeconds) {      await this.schedule(parsed.when.delayInSeconds, "sendReminder", {        message: parsed.description,      });    } else if (parsed.when.type === "cron" && parsed.when.cron) {      await this.schedule(parsed.when.cron, "sendReminder", {        message: parsed.description,      });    }  }
  async sendReminder(payload) {    console.log(`Reminder: ${payload.message}`);  }}
```

TypeScript

```
import { getSchedulePrompt, scheduleSchema } from "agents";import { generateObject } from "ai";import { openai } from "@ai-sdk/openai";
class SmartScheduler extends Agent {  async parseScheduleRequest(userInput: string) {    const result = await generateObject({      model: openai("gpt-4o"),      system: getSchedulePrompt({ date: new Date() }),      prompt: userInput,      schema: scheduleSchema,    });
    return result.object;  }
  async handleUserRequest(input: string) {    // Parse: "remind me to call mom tomorrow at 3pm"    const parsed = await this.parseScheduleRequest(input);
    // parsed = {    //   description: "call mom",    //   when: {    //     type: "scheduled",    //     date: "2025-01-30T15:00:00Z"    //   }    // }
    if (parsed.when.type === "scheduled" && parsed.when.date) {      await this.schedule(new Date(parsed.when.date), "sendReminder", {        message: parsed.description,      });    } else if (parsed.when.type === "delayed" && parsed.when.delayInSeconds) {      await this.schedule(parsed.when.delayInSeconds, "sendReminder", {        message: parsed.description,      });    } else if (parsed.when.type === "cron" && parsed.when.cron) {      await this.schedule(parsed.when.cron, "sendReminder", {        message: parsed.description,      });    }  }
  async sendReminder(payload: { message: string }) {    console.log(`Reminder: ${payload.message}`);  }}
```

### `scheduleSchema`

A Zod schema for validating parsed scheduling data. Uses a discriminated union on `when.type` so each variant only contains the fields it needs:

* [  JavaScript ](#tab-panel-6413)
* [  TypeScript ](#tab-panel-6414)

JavaScript

```
import { scheduleSchema } from "agents";
// The schema is a discriminated union:// {//   description: string,//   when://     | { type: "scheduled", date: string }       // ISO 8601 date string//     | { type: "delayed", delayInSeconds: number }//     | { type: "cron", cron: string }//     | { type: "no-schedule" }// }
```

TypeScript

```
import { scheduleSchema } from "agents";
// The schema is a discriminated union:// {//   description: string,//   when://     | { type: "scheduled", date: string }       // ISO 8601 date string//     | { type: "delayed", delayInSeconds: number }//     | { type: "cron", cron: string }//     | { type: "no-schedule" }// }
```

Note

Dates are returned as ISO 8601 strings (not `Date` objects) for compatibility with both Zod v3 and v4 JSON schema generation.

## Scheduling vs Queue vs Workflows

| Feature            | Queue              | Scheduling        | Workflows           |
| ------------------ | ------------------ | ----------------- | ------------------- |
| **When**           | Immediately (FIFO) | Future time       | Future time         |
| **Execution**      | Sequential         | At scheduled time | Multi-step          |
| **Retries**        | Built-in           | Built-in          | Automatic           |
| **Persistence**    | SQLite             | SQLite            | Workflow engine     |
| **Recurring**      | No                 | Yes (cron)        | No (use scheduling) |
| **Complex logic**  | No                 | No                | Yes                 |
| **Human approval** | No                 | No                | Yes                 |

Use Queue when:

* You need background processing without blocking the response
* Tasks should run ASAP but do not need to block
* Order matters (FIFO)

Use Scheduling when:

* Tasks need to run at a specific time
* You need recurring jobs (cron)
* Delayed execution (debouncing, retries)

Use Workflows when:

* Multi-step processes with dependencies
* Automatic retries with backoff
* Human-in-the-loop approvals
* Long-running tasks (minutes to hours)

## API reference

### `schedule()`

TypeScript

```
async schedule<T>(  when: Date | string | number,  callback: keyof this,  payload?: T,  options?: { retry?: RetryOptions; idempotent?: boolean }): Promise<Schedule<T>>
```

Schedule a task for future execution.

**Parameters:**

* `when` \- When to execute: `number` (seconds delay), `Date` (specific time), or `string` (cron expression)
* `callback` \- Name of the method to call
* `payload` \- Data to pass to the callback (must be JSON-serializable)
* `options.retry` \- Optional retry configuration. Refer to [Retries](https://developers.cloudflare.com/agents/runtime/execution/retries/) for details
* `options.idempotent` \- Deduplicate by callback + payload. Defaults to `true` for cron schedules, `false` for delayed and Date-based schedules

**Returns:** A `Schedule` object with the task details

**Idempotency:**

Cron schedules are idempotent by default — calling `schedule("0 * * * *", "tick")` multiple times with the same callback, cron expression, and payload returns the existing schedule instead of creating a duplicate. Set `idempotent: false` to override this.

For delayed and Date-based schedules, set `idempotent: true` to opt in to the same dedup behavior (matched on callback + payload). This is especially useful when calling `schedule()` in `onStart()` to avoid accumulating duplicate rows across Durable Object restarts:

* [  JavaScript ](#tab-panel-6405)
* [  TypeScript ](#tab-panel-6406)

JavaScript

```
class MyAgent extends Agent {  async onStart() {    // Without idempotent: true, this creates a new row on every DO restart    await this.schedule(3600, "hourlyCleanup", {}, { idempotent: true });  }}
```

TypeScript

```
class MyAgent extends Agent {  async onStart() {    // Without idempotent: true, this creates a new row on every DO restart    await this.schedule(3600, "hourlyCleanup", {}, { idempotent: true });  }}
```

Warning

Tasks that set a callback for a method that does not exist will throw an exception. Ensure that the method named in the `callback` argument exists on your `Agent` class.

### `scheduleEvery()`

TypeScript

```
async scheduleEvery<T>(  intervalSeconds: number,  callback: keyof this,  payload?: T,  options?: { retry?: RetryOptions }): Promise<Schedule<T>>
```

Schedule a task to run repeatedly at a fixed interval.

**Parameters:**

* `intervalSeconds` \- Number of seconds between executions (must be greater than 0)
* `callback` \- Name of the method to call
* `payload` \- Data to pass to the callback (must be JSON-serializable)
* `options.retry` \- Optional retry configuration. Refer to [Retries](https://developers.cloudflare.com/agents/runtime/execution/retries/) for details.

**Returns:** A `Schedule` object with `type: "interval"`

**Behavior:**

* First execution occurs after `intervalSeconds` (not immediately)
* If callback is still running when next execution is due, it is skipped (overlap prevention)
* If callback throws an error, the interval continues
* Cancel with `cancelSchedule(id)` to stop the entire interval

### `getScheduleById()`

TypeScript

```
async getScheduleById(id: string): Promise<Schedule<unknown> | undefined>
```

Get a scheduled task by ID. Returns `undefined` if not found. This method works in both top-level agents and sub-agents.

### `listSchedules()`

TypeScript

```
async listSchedules(criteria?: {  id?: string;  type?: "scheduled" | "delayed" | "cron" | "interval";  timeRange?: { start?: Date; end?: Date };}): Promise<Schedule<unknown>[]>
```

Get scheduled tasks matching the criteria. This method works in both top-level agents and sub-agents.

### `getSchedule()`

TypeScript

```
getSchedule<T>(id: string): Schedule<T> | undefined
```

Deprecated. Get a scheduled task by ID synchronously. This method only works in top-level agents. Use `await this.getScheduleById(id)` instead.

### `getSchedules()`

TypeScript

```
getSchedules<T>(criteria?: {  id?: string;  type?: "scheduled" | "delayed" | "cron" | "interval";  timeRange?: { start?: Date; end?: Date };}): Schedule<T>[]
```

Deprecated. Get scheduled tasks matching the criteria synchronously. This method only works in top-level agents. Use `await this.listSchedules(criteria)` instead.

### `cancelSchedule()`

TypeScript

```
async cancelSchedule(id: string): Promise<boolean>
```

Cancel a scheduled task. Returns `true` if cancelled, `false` if not found.

### `keepAlive()`

TypeScript

```
async keepAlive(): Promise<() => void>
```

Prevent the Durable Object from being evicted due to inactivity by holding a 30-second alarm-backed heartbeat reference. Returns a disposer function that releases the heartbeat when called. The disposer is idempotent — calling it multiple times is safe.

Always call the disposer when the work is done — otherwise the heartbeat continues indefinitely.

* [  JavaScript ](#tab-panel-6415)
* [  TypeScript ](#tab-panel-6416)

JavaScript

```
const dispose = await this.keepAlive();try {  // Long-running work that must not be interrupted  const result = await longRunningComputation();  await sendResults(result);} finally {  dispose();}
```

TypeScript

```
const dispose = await this.keepAlive();try {  // Long-running work that must not be interrupted  const result = await longRunningComputation();  await sendResults(result);} finally {  dispose();}
```

### `keepAliveWhile()`

TypeScript

```
async keepAliveWhile<T>(fn: () => Promise<T>): Promise<T>
```

Run an async function while keeping the Durable Object alive. The heartbeat is automatically started before the function runs and stopped when it completes (whether it succeeds or throws). Returns the value returned by the function.

This is the recommended way to use `keepAlive` — it guarantees cleanup.

* [  JavaScript ](#tab-panel-6411)
* [  TypeScript ](#tab-panel-6412)

JavaScript

```
const result = await this.keepAliveWhile(async () => {  const data = await longRunningComputation();  return data;});
```

TypeScript

```
const result = await this.keepAliveWhile(async () => {  const data = await longRunningComputation();  return data;});
```

## Keeping the agent alive

Durable Objects are evicted after a period of inactivity (typically 70-140 seconds with no incoming requests, WebSocket messages, or alarms). During long-running operations — streaming LLM responses, waiting on external APIs, running multi-step computations — the agent can be evicted mid-flight.

`keepAlive()` prevents this by holding an in-memory heartbeat reference and using the Durable Object alarm system directly. The alarm firing itself resets the inactivity timer.

* The heartbeat does not conflict with your own schedules because the alarm system multiplexes through a single alarm slot.
* No schedule rows are created, and the heartbeat is invisible to `listSchedules()`.
* Multiple concurrent `keepAlive()` calls use a reference count, so one disposer does not release another caller's heartbeat.
* Inside sub-agents, `keepAlive()` delegates that heartbeat reference to the top-level parent because facets do not have independent alarm slots.

### Multiple concurrent callers

Each `keepAlive()` call returns an independent disposer:

* [  JavaScript ](#tab-panel-6417)
* [  TypeScript ](#tab-panel-6418)

JavaScript

```
const dispose1 = await this.keepAlive();const dispose2 = await this.keepAlive();
// Both heartbeats are activedispose1(); // Only cancels the first heartbeat// Agent is still alive via dispose2's heartbeat
dispose2(); // Now the agent can go idle
```

TypeScript

```
const dispose1 = await this.keepAlive();const dispose2 = await this.keepAlive();
// Both heartbeats are activedispose1(); // Only cancels the first heartbeat// Agent is still alive via dispose2's heartbeat
dispose2(); // Now the agent can go idle
```

### AIChatAgent

`AIChatAgent` automatically calls `keepAlive()` during streaming responses. You do not need to add it yourself when using `AIChatAgent` — every LLM stream is protected from idle eviction by default.

### When to use keepAlive

| Scenario                                    | Use keepAlive?                         |
| ------------------------------------------- | -------------------------------------- |
| Streaming LLM responses via AIChatAgent     | No — already built in                  |
| Long-running computation in a custom Agent  | Yes                                    |
| Waiting on a slow external API call         | Yes                                    |
| Multi-step tool execution                   | Yes                                    |
| Short request-response handlers             | No — not needed                        |
| Background work via scheduling or workflows | No — alarms already keep the DO active |

## Limits

* **Maximum tasks:** Limited by SQLite storage (each task is a row). Practical limit is tens of thousands per agent.
* **Task size:** Each task (including payload) can be up to 2MB.
* **Minimum delay:** 0 seconds (runs on next alarm tick)
* **Cron precision:** Minute-level (not seconds)
* **Interval precision:** Second-level
* **Cron jobs:** After execution, automatically rescheduled for the next occurrence
* **Interval jobs:** After execution, rescheduled for `now + intervalSeconds`; skipped if still running

## Next steps

[ Push notifications ](https://developers.cloudflare.com/agents/communication-channels/webhooks/push-notifications/) Send browser push notifications using scheduling and web-push. 

[ Queue tasks ](https://developers.cloudflare.com/agents/runtime/execution/queue-tasks/) Immediate background task processing. 

[ Run Workflows ](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/) Durable multi-step background processing. 

[ Agents API ](https://developers.cloudflare.com/agents/runtime/agents-api/) Complete API reference for the Agents SDK.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/#page","headline":"Schedule tasks · Cloudflare Agents docs","description":"Schedule delayed, date-based, cron, and interval tasks on Agents with persistent SQLite-backed execution.","url":"https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/execution/","name":"Execution"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/execution/schedule-tasks/","name":"Schedule tasks"}}]}
```

---

---
title: Sub-agents
description: Spawn child agents with isolated storage and typed RPC using subAgent(), abortSubAgent(), and deleteSubAgent().
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Sub-agents

Spawn child agents as co-located Durable Objects with their own isolated SQLite storage. The parent gets a typed RPC stub for calling methods on the child — every public method on the child class is callable as a remote procedure call with Promise-wrapped return types.

Use sub-agents when a single user or entity owns an open-ended set of long-lived agents, such as chats, documents, sessions, shards, or projects. Each sub-agent runs in parallel with its own state while the parent coordinates discovery, access control, and lifecycle.

If you want a parent chat agent to dispatch another chat-capable agent during a single turn and render that child's progress inline, use [Agents as tools](https://developers.cloudflare.com/agents/runtime/execution/agent-tools/). Agents as tools are built on sub-agents, but add a parent-side run registry, streaming `agent-tool-event` frames, replay, cancellation, and cleanup.

## Quick start

* [  JavaScript ](#tab-panel-6439)
* [  TypeScript ](#tab-panel-6440)

JavaScript

```
import { Agent } from "agents";
export class Orchestrator extends Agent {  async delegateWork() {    const researcher = await this.subAgent(Researcher, "research-1");    const findings = await researcher.search("cloudflare agents sdk");    return findings;  }}
export class Researcher extends Agent {  async search(query) {    const results = await fetch(`https://api.example.com/search?q=${query}`);    return results.json();  }}
```

TypeScript

```
import { Agent } from "agents";
export class Orchestrator extends Agent {  async delegateWork() {    const researcher = await this.subAgent(Researcher, "research-1");    const findings = await researcher.search("cloudflare agents sdk");    return findings;  }}
export class Researcher extends Agent {  async search(query: string) {    const results = await fetch(`https://api.example.com/search?q=${query}`);    return results.json();  }}
```

Both classes must be exported from the worker entry point. No separate Durable Object bindings are needed for child-only classes — child classes are discovered automatically via `ctx.exports`.

* [  wrangler.jsonc ](#tab-panel-6427)
* [  wrangler.toml ](#tab-panel-6428)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": [    "nodejs_compat"  ],  "durable_objects": {    "bindings": [      {        "class_name": "Orchestrator",        "name": "Orchestrator"      }    ]  },  "migrations": [    {      "new_sqlite_classes": [        "Orchestrator"      ],      "tag": "v1"    }  ]}
```

TOML

```
# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = ["nodejs_compat"]
[[durable_objects.bindings]]class_name = "Orchestrator"name = "Orchestrator"
[[migrations]]new_sqlite_classes = ["Orchestrator"]tag = "v1"
```

Only the top-level parent agent needs a Durable Object binding and migration. Child agents are created as facets of the parent — they share the same machine but have fully isolated SQLite storage.

## subAgent

Get or create a named sub-agent. The first call for a given name triggers the child's `onStart()`. Subsequent calls return the existing instance.

* [  JavaScript ](#tab-panel-6429)
* [  TypeScript ](#tab-panel-6430)

JavaScript

```
class Agent {}
```

TypeScript

```
class Agent {  async subAgent<T extends Agent>(    cls: SubAgentClass<T>,    name: string,  ): Promise<SubAgentStub<T>>;}
```

| Parameter | Type             | Description                                                                                                      |
| --------- | ---------------- | ---------------------------------------------------------------------------------------------------------------- |
| cls       | SubAgentClass<T> | The Agent subclass. Must be exported from the worker entry point, and the export name must match the class name. |
| name      | string           | Unique name for this child instance. The same name always returns the same child.                                |

Returns a `SubAgentStub<T>` — a typed RPC stub where every user-defined method on `T` is available as a Promise-returning remote call.

### SubAgentStub

The stub exposes all public instance methods you define on the child class. Methods inherited from `Agent` (lifecycle hooks, `setState`, `broadcast`, `sql`, and so on) are excluded — only your custom methods appear on the stub.

Return types are automatically wrapped in `Promise` if they are not already:

* [  JavaScript ](#tab-panel-6441)
* [  TypeScript ](#tab-panel-6442)

JavaScript

```
class MyChild extends Agent {  greet(name) {    return `Hello, ${name}`;  }  async fetchData(url) {    return fetch(url).then((r) => r.json());  }}
// On the stub:// greet(name: string) => Promise<string>       (sync → wrapped)// fetchData(url: string) => Promise<unknown>   (already async → unchanged)
```

TypeScript

```
class MyChild extends Agent {  greet(name: string): string {    return `Hello, ${name}`;  }  async fetchData(url: string): Promise<unknown> {    return fetch(url).then((r) => r.json());  }}
// On the stub:// greet(name: string) => Promise<string>       (sync → wrapped)// fetchData(url: string) => Promise<unknown>   (already async → unchanged)
```

### Requirements

* The child class must extend `Agent`
* The child class must be exported from the worker entry point (`export class MyChild extends Agent`)
* The export name must match the class name — `export { Foo as Bar }` is not supported
* The top-level parent class must be bound as a Durable Object namespace in `wrangler.jsonc`
* A facet-only child class does not need to be registered under `new_sqlite_classes` unless the same class is also bound as a top-level Durable Object elsewhere
* Nested facet parents do not need their own top-level Durable Object bindings; the runtime resolves nested children through the root parent namespace
* The child class name cannot be `Sub`, because `/sub/` is reserved as the URL separator for nested routes

### Notes for testing

Tests that use `@cloudflare/vitest-pool-workers` may need to list facet classes as test-only Durable Object bindings so `ctx.exports` provides a facet-compatible class value. Keep those facet classes out of `new_sqlite_classes`; the extra binding belongs only in test `wrangler.jsonc` files and is not a production Worker requirement.

## abortSubAgent

Forcefully stop a running sub-agent. The child stops executing immediately and restarts on the next `subAgent()` call. Storage is preserved — only the running instance is killed.

* [  JavaScript ](#tab-panel-6431)
* [  TypeScript ](#tab-panel-6432)

JavaScript

```
class Agent {}
```

TypeScript

```
class Agent {  abortSubAgent(cls: SubAgentClass, name: string, reason?: unknown): void;}
```

| Parameter | Type          | Description                                       |
| --------- | ------------- | ------------------------------------------------- |
| cls       | SubAgentClass | The Agent subclass used when creating the child   |
| name      | string        | Name of the child to abort                        |
| reason    | unknown       | Error thrown to any pending or future RPC callers |

Abort is transitive — if the child has its own sub-agents, they are also aborted.

## deleteSubAgent

Abort the child (if running) and permanently wipe its storage. The next `subAgent()` call creates a fresh instance with empty SQLite.

* [  JavaScript ](#tab-panel-6433)
* [  TypeScript ](#tab-panel-6434)

JavaScript

```
class Agent {}
```

TypeScript

```
class Agent {  deleteSubAgent(cls: SubAgentClass, name: string): void;}
```

| Parameter | Type          | Description                                     |
| --------- | ------------- | ----------------------------------------------- |
| cls       | SubAgentClass | The Agent subclass used when creating the child |
| name      | string        | Name of the child to delete                     |

Deletion is transitive — the child's own sub-agents are also deleted.

## Introspection and access control

### `hasSubAgent`

Check whether a child has been spawned and not deleted. This is backed by a framework-maintained SQLite registry.

* [  JavaScript ](#tab-panel-6435)
* [  TypeScript ](#tab-panel-6436)

JavaScript

```
if (!this.hasSubAgent(Chat, id)) {  return new Response("Not found", { status: 404 });}
```

TypeScript

```
if (!this.hasSubAgent(Chat, id)) {  return new Response("Not found", { status: 404 });}
```

### `listSubAgents`

List spawned sub-agents, optionally filtered by class. Rows are returned in creation order.

* [  JavaScript ](#tab-panel-6437)
* [  TypeScript ](#tab-panel-6438)

JavaScript

```
const chats = this.listSubAgents(Chat);// [{ className: "Chat", name: "chat-abc", createdAt: 1700000000000 }]
```

TypeScript

```
const chats = this.listSubAgents(Chat);// [{ className: "Chat", name: "chat-abc", createdAt: 1700000000000 }]
```

### `onBeforeSubAgent`

Override this middleware hook on the parent to gate, mutate, or short-circuit incoming `/sub/` requests before the framework wakes the child. It mirrors `onBeforeConnect` and `onBeforeRequest`.

The hook can return:

| Return value | Effect                                    |
| ------------ | ----------------------------------------- |
| void         | Forward the original request to the child |
| Request      | Forward a modified request                |
| Response     | Short-circuit and do not wake the child   |

* [  JavaScript ](#tab-panel-6445)
* [  TypeScript ](#tab-panel-6446)

JavaScript

```
export class Inbox extends Agent {  async onBeforeSubAgent(_request, { className, name }) {    // Strict registry gate: only allow clients to reach chats that were created.    if (!this.hasSubAgent(className, name)) {      return new Response(`${className} "${name}" not found`, {        status: 404,      });    }  }}
```

TypeScript

```
export class Inbox extends Agent {  override async onBeforeSubAgent(_request, { className, name }) {    // Strict registry gate: only allow clients to reach chats that were created.    if (!this.hasSubAgent(className, name)) {      return new Response(`${className} "${name}" not found`, {        status: 404,      });    }  }}
```

WebSocket upgrade requests flow through this hook the same way as plain HTTP requests. If you return a modified `Request`, preserve the original WebSocket upgrade headers.

## Parent and child identity

Sub-agents know who their parent is through `this.parentPath` and `this.selfPath`.

* [  JavaScript ](#tab-panel-6447)
* [  TypeScript ](#tab-panel-6448)

JavaScript

```
// Inside a Chat spawned by Inbox:this.parentPath;// [{ className: "Inbox", name: "user-123" }]
this.selfPath;// [//   { className: "Inbox", name: "user-123" },//   { className: "Chat", name: "chat-abc" }// ]
```

TypeScript

```
// Inside a Chat spawned by Inbox:this.parentPath;// [{ className: "Inbox", name: "user-123" }]
this.selfPath;// [//   { className: "Inbox", name: "user-123" },//   { className: "Chat", name: "chat-abc" }// ]
```

`parentPath` is root-first, so the direct parent is always `parentPath.at(-1)`. Top-level agents have `parentPath === []`.

Use `parentAgent(Cls)` from a sub-agent to get a typed RPC stub to its immediate parent:

* [  JavaScript ](#tab-panel-6443)
* [  TypeScript ](#tab-panel-6444)

JavaScript

```
const inbox = await this.parentAgent(Inbox);await inbox.recordTurn(this.name, "...");
```

TypeScript

```
const inbox = await this.parentAgent(Inbox);await inbox.recordTurn(this.name, "...");
```

`parentAgent()` resolves the direct parent even when that parent is itself a facet-only sub-agent, using a root-side RPC bridge under the hood. This gives you typed method calls to the immediate parent without requiring every nested parent class to be bound as a top-level Durable Object.

For grandparents and further ancestors, iterate `this.parentPath` and call `getAgentByName()` directly. If the binding name does not match the class name, call `getAgentByName(env.MY_BINDING, this.parentPath.at(-1)!.name)` instead of `parentAgent()`.

## Client routing

### `useAgent({ sub })`

Extend any `useAgent` call with a `sub` chain to connect to a descendant facet:

* [  JavaScript ](#tab-panel-6449)
* [  TypeScript ](#tab-panel-6450)

JavaScript

```
const chat = useAgent({  agent: "Inbox",  name: userId,  sub: [{ agent: "Chat", name: chatId }],});
```

TypeScript

```
const chat = useAgent({  agent: "Inbox",  name: userId,  sub: [{ agent: "Chat", name: chatId }],});
```

The hook builds a URL like `/agents/inbox/user-123/sub/chat/chat-abc` and opens a direct WebSocket to the `Chat` child. Every other `useAgent` feature works as usual: state sync, `stub` calls, `@callable` RPC, and `useAgentChat` on top of the returned socket.

### Custom HTTP routing

For fetch handlers that do their own top-level URL parsing, use `routeSubAgentRequest()` to dispatch a request into a sub-agent from an already-resolved parent stub:

* [  JavaScript ](#tab-panel-6455)
* [  TypeScript ](#tab-panel-6456)

JavaScript

```
import { getAgentByName, routeSubAgentRequest } from "agents";
export default {  async fetch(request, env) {    const url = new URL(request.url);    const match = url.pathname.match(/^\/api\/u\/([^/]+)(\/.*)$/);    if (!match) return new Response("Not found", { status: 404 });
    const [, userId, rest] = match;    const parent = await getAgentByName(env.Inbox, userId);    return routeSubAgentRequest(request, parent, { fromPath: rest });  },};
```

TypeScript

```
import { getAgentByName, routeSubAgentRequest } from "agents";
export default {  async fetch(request: Request, env: Env) {    const url = new URL(request.url);    const match = url.pathname.match(/^\/api\/u\/([^/]+)(\/.*)$/);    if (!match) return new Response("Not found", { status: 404 });
    const [, userId, rest] = match;    const parent = await getAgentByName(env.Inbox, userId);    return routeSubAgentRequest(request, parent, { fromPath: rest });  },};
```

`fromPath` takes the sub-agent tail, such as `/sub/chat/chat-abc`. The helper parses it, runs the parent's `onBeforeSubAgent` hook, and forwards the request into the facet.

### External typed RPC

From inside the parent Durable Object, `this.subAgent(Cls, name)` returns a typed stub. From outside the parent, use `getSubAgentByName()`:

* [  JavaScript ](#tab-panel-6451)
* [  TypeScript ](#tab-panel-6452)

JavaScript

```
import { getAgentByName, getSubAgentByName } from "agents";
const inbox = await getAgentByName(env.Inbox, userId);const chat = await getSubAgentByName(inbox, Chat, chatId);
await chat.addMessage({ role: "user", content: "hello" });
```

TypeScript

```
import { getAgentByName, getSubAgentByName } from "agents";
const inbox = await getAgentByName(env.Inbox, userId);const chat = await getSubAgentByName(inbox, Chat, chatId);
await chat.addMessage({ role: "user", content: "hello" });
```

`getSubAgentByName()` returns an RPC-only proxy. Method calls work, but `.fetch()` throws. Use `routeSubAgentRequest()` for HTTP and WebSocket forwarding.

## Storage isolation

Each sub-agent has its own SQLite database, completely isolated from the parent and from other sub-agents. A parent writing to `this.sql` and a child writing to `this.sql` operate on different databases:

* [  JavaScript ](#tab-panel-6461)
* [  TypeScript ](#tab-panel-6462)

JavaScript

```
export class Parent extends Agent {  async demonstrate() {    this.sql`INSERT INTO parent_data (key, value) VALUES ('color', 'blue')`;
    const child = await this.subAgent(Child, "child-1");    await child.increment("clicks");
    // Parent's SQL and child's SQL are completely separate  }}
export class Child extends Agent {  async increment(key) {    this      .sql`CREATE TABLE IF NOT EXISTS counters (key TEXT PRIMARY KEY, value INTEGER DEFAULT 0)`;    this      .sql`INSERT INTO counters (key, value) VALUES (${key}, 1) ON CONFLICT(key) DO UPDATE SET value = value + 1`;    const row = this.sql`SELECT value FROM counters WHERE key = ${key}`.one();    return row?.value ?? 0;  }}
```

TypeScript

```
export class Parent extends Agent {  async demonstrate() {    this.sql`INSERT INTO parent_data (key, value) VALUES ('color', 'blue')`;
    const child = await this.subAgent(Child, "child-1");    await child.increment("clicks");
    // Parent's SQL and child's SQL are completely separate  }}
export class Child extends Agent {  async increment(key: string): Promise<number> {    this      .sql`CREATE TABLE IF NOT EXISTS counters (key TEXT PRIMARY KEY, value INTEGER DEFAULT 0)`;    this      .sql`INSERT INTO counters (key, value) VALUES (${key}, 1) ON CONFLICT(key) DO UPDATE SET value = value + 1`;    const row = this.sql<{      value: number;    }>`SELECT value FROM counters WHERE key = ${key}`.one();    return row?.value ?? 0;  }}
```

## Naming and identity

Two different classes can share the same user-facing name — they are resolved independently. The internal key is a composite of class name and facet name:

* [  JavaScript ](#tab-panel-6453)
* [  TypeScript ](#tab-panel-6454)

JavaScript

```
const counter = await this.subAgent(Counter, "shared-name");const logger = await this.subAgent(Logger, "shared-name");// These are two separate sub-agents with separate storage
```

TypeScript

```
const counter = await this.subAgent(Counter, "shared-name");const logger = await this.subAgent(Logger, "shared-name");// These are two separate sub-agents with separate storage
```

The child's `this.name` property returns the facet name (not the parent's name):

* [  JavaScript ](#tab-panel-6457)
* [  TypeScript ](#tab-panel-6458)

JavaScript

```
export class Child extends Agent {  getName() {    return this.name; // Returns "shared-name", not the parent's ID  }}
```

TypeScript

```
export class Child extends Agent {  getName(): string {    return this.name; // Returns "shared-name", not the parent's ID  }}
```

## Patterns

### Parallel sub-agents

Run multiple sub-agents concurrently:

* [  JavaScript ](#tab-panel-6459)
* [  TypeScript ](#tab-panel-6460)

JavaScript

```
export class Orchestrator extends Agent {  async runAll(queries) {    const results = await Promise.all(      queries.map(async (query, i) => {        const worker = await this.subAgent(Researcher, `research-${i}`);        return worker.search(query);      }),    );    return results;  }}
```

TypeScript

```
export class Orchestrator extends Agent {  async runAll(queries: string[]) {    const results = await Promise.all(      queries.map(async (query, i) => {        const worker = await this.subAgent(Researcher, `research-${i}`);        return worker.search(query);      }),    );    return results;  }}
```

### Nested sub-agents

Sub-agents can spawn their own sub-agents, forming a tree:

* [  JavaScript ](#tab-panel-6463)
* [  TypeScript ](#tab-panel-6464)

JavaScript

```
export class Manager extends Agent {  async delegate(task) {    const team = await this.subAgent(TeamLead, "team-a");    return team.assign(task);  }}
export class TeamLead extends Agent {  async assign(task) {    const worker = await this.subAgent(Worker, "worker-1");    return worker.execute(task);  }}
export class Worker extends Agent {  async execute(task) {    return { completed: task };  }}
```

TypeScript

```
export class Manager extends Agent {  async delegate(task: string) {    const team = await this.subAgent(TeamLead, "team-a");    return team.assign(task);  }}
export class TeamLead extends Agent {  async assign(task: string) {    const worker = await this.subAgent(Worker, "worker-1");    return worker.execute(task);  }}
export class Worker extends Agent {  async execute(task: string) {    return { completed: task };  }}
```

### Callback streaming

Pass an `RpcTarget` callback to stream results from a sub-agent back to the parent:

* [  JavaScript ](#tab-panel-6465)
* [  TypeScript ](#tab-panel-6466)

JavaScript

```
import { RpcTarget } from "cloudflare:workers";
class StreamCollector extends RpcTarget {  chunks = [];  onChunk(text) {    this.chunks.push(text);  }}
export class Parent extends Agent {  async streamFromChild() {    const child = await this.subAgent(Streamer, "streamer-1");    const collector = new StreamCollector();    await child.generate("Write a poem", collector);    return collector.chunks;  }}
export class Streamer extends Agent {  async generate(prompt, callback) {    const chunks = ["Once ", "upon ", "a ", "time..."];    for (const chunk of chunks) {      callback.onChunk(chunk);    }  }}
```

TypeScript

```
import { RpcTarget } from "cloudflare:workers";
class StreamCollector extends RpcTarget {  chunks: string[] = [];  onChunk(text: string) {    this.chunks.push(text);  }}
export class Parent extends Agent {  async streamFromChild() {    const child = await this.subAgent(Streamer, "streamer-1");    const collector = new StreamCollector();    await child.generate("Write a poem", collector);    return collector.chunks;  }}
export class Streamer extends Agent {  async generate(prompt: string, callback: StreamCollector) {    const chunks = ["Once ", "upon ", "a ", "time..."];    for (const chunk of chunks) {      callback.onChunk(chunk);    }  }}
```

## Scheduling and durable work

Sub-agents can schedule their own callbacks and run durable fibers:

| Method                              | Behavior in sub-agent                                                      |
| ----------------------------------- | -------------------------------------------------------------------------- |
| schedule() / scheduleEvery()        | Work normally and run callbacks inside the sub-agent                       |
| cancelSchedule()                    | Works for schedules owned by the calling sub-agent                         |
| getScheduleById() / listSchedules() | Work and return schedules scoped to the calling sub-agent                  |
| keepAlive() / keepAliveWhile()      | Work by delegating the heartbeat to the top-level parent                   |
| runFiber()                          | Works, with fiber rows and snapshots stored in the child's SQLite database |
| setState()                          | Works normally and writes to the child's own storage                       |
| this.sql                            | Works normally and points at the child's own SQLite database               |
| subAgent()                          | Works, so sub-agents can spawn their own children                          |

The top-level parent still owns the physical Durable Object alarm because facets do not have independent alarm slots. The Agents SDK records which child owns each scheduled callback or recovery check, wakes the parent, and routes the work back into the child. The callback still runs with the sub-agent as `this`, so it uses the child's state, SQLite storage, and `getCurrentAgent()` context.

The older synchronous `getSchedule()` and `getSchedules()` APIs throw inside sub-agents because scheduled rows are stored on the top-level parent. Use `getScheduleById()` and `listSchedules()` instead.

Calling `this.destroy()` inside a sub-agent delegates cleanup to the parent. The parent cancels that sub-agent's schedules, removes recovery metadata for the sub-agent and its descendants, removes the registry entry, and asks the runtime to wipe the child storage. Treat `this.destroy()` as fire-and-forget because deleting the sub-agent can abort its isolate before the method returns cleanly.

### Workflows from sub-agents

Sub-agents can also start [Workflows](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/) with `this.runWorkflow()`. Workflow tracking is local to the sub-agent's SQLite database, and `AgentWorkflow.agent` routes RPC, callbacks, state updates, and broadcasts back to the originating sub-agent. Parent agents do not automatically list or control child-started workflows.

Because `SubAgentStub<T>` only exposes user-defined child methods, add child wrapper methods for controls such as `getWorkflow()`, `approveWorkflow()`, or `terminateWorkflow()`, then call those wrappers through `await this.subAgent(Child, name)`. If you pass `runWorkflow(..., { agentBinding })` from a sub-agent, use the root Agent binding name, not a child binding name.

For sub-agent workflow origins, `AgentWorkflow.agent` is RPC-only. Use it to call Agent methods, but use `routeSubAgentRequest()` or the nested `/agents/{parent}/{name}/sub/{child}/{name}` URL shape for external HTTP or WebSocket routing instead of `this.agent.fetch()`.

## Example

[ Multi-session chat example ](https://github.com/cloudflare/agents/tree/main/examples/multi-ai-chat) Build an inbox where each chat is an AIChatAgent sub-agent with isolated state and direct client routing. 

## Related

* [Think](https://developers.cloudflare.com/agents/harnesses/think/) — `chat()` method for streaming AI turns through sub-agents
* [Long-running agents](https://developers.cloudflare.com/agents/concepts/agentic-patterns/long-running-agents/) — sub-agent delegation in the context of multi-week agent lifetimes
* [Callable methods](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/) — RPC via `@callable` and service bindings
* [Agents as tools](https://developers.cloudflare.com/agents/runtime/execution/agent-tools/) — run Think or `AIChatAgent` sub-agents as retained, streaming tools
* [Schedule tasks](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) — scheduling primitives for top-level agents and sub-agents

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/execution/sub-agents/#page","headline":"Sub-agents · Cloudflare Agents docs","description":"Spawn child agents with isolated storage and typed RPC using subAgent(), abortSubAgent(), and deleteSubAgent().","url":"https://developers.cloudflare.com/agents/runtime/execution/sub-agents/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-26","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/execution/","name":"Execution"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/execution/sub-agents/","name":"Sub-agents"}}]}
```

---

---
title: Agent class internals
description: Explore how the Agent class extends Durable Objects to provide state, WebSockets, scheduling, and RPC.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Agent class internals

The core of the `agents` library is the `Agent` class. You extend it, override a few methods, and get state management, WebSockets, scheduling, RPC, and more for free. This page explains how `Agent` is built, layer by layer, so you understand what is happening under the hood.

The snippets shown here are illustrative and do not necessarily represent best practices. For the full API, refer to the [API reference](https://developers.cloudflare.com/agents/runtime/) and the [source code ↗](https://github.com/cloudflare/agents/blob/main/packages/agents/src/index.ts).

## What is the Agent?

The `Agent` class is an extension of `DurableObject` — agents _are_ Durable Objects. If you are not familiar with Durable Objects, read [What are Durable Objects](https://developers.cloudflare.com/durable-objects/) first. At their core, Durable Objects are globally addressable (each instance has a unique ID), single-threaded compute instances with long-term storage (key-value and SQLite).

`Agent` does not extend `DurableObject` directly. It extends `Server` from the [partyserver ↗](https://github.com/cloudflare/partykit/tree/main/packages/partyserver) package, which extends `DurableObject`. Think of it as layers: **DurableObject** \> **Server** \> **Agent**.

## Layer 0: Durable Object

Let's briefly consider which primitives are exposed by Durable Objects so we understand how the outer layers make use of them. The Durable Object class comes with:

### `constructor`

TypeScript

```
constructor(ctx: DurableObjectState, env: Env) {}
```

The Workers runtime always calls the constructor to handle things internally. This means two things:

1. While the constructor is called every time the Durable Object is initialized, the signature is fixed. Developers cannot add or update parameters from the constructor.
2. Instead of instantiating the class manually, developers must use the binding APIs and do it through the [DurableObjectNamespace](https://developers.cloudflare.com/durable-objects/api/namespace/).

### RPC

By writing a Durable Object class which inherits from the built-in type `DurableObject`, public methods are exposed as RPC methods, which developers can call using a [DurableObjectStub from a Worker](https://developers.cloudflare.com/durable-objects/best-practices/create-durable-object-stubs-and-send-requests/#invoking-methods-on-a-durable-object).

TypeScript

```
// This instance could've been active, hibernated,// not initialized or maybe had never even been created!const stub = env.MY_DO.getByName("foo");
// We can call any public method on the class. The runtime// ensures the constructor is called if the instance was not active.await stub.bar();
```

### `fetch()`

Durable Objects can take a `Request` from a Worker and send a `Response` back. This can only be done through the [fetch](https://developers.cloudflare.com/durable-objects/best-practices/create-durable-object-stubs-and-send-requests/#invoking-the-fetch-handler) method (which the developer must implement).

### WebSockets

Durable Objects include first-class support for [WebSockets](https://developers.cloudflare.com/durable-objects/best-practices/websockets/). A Durable Object can accept a WebSocket it receives from a `Request` in `fetch` and forget about it. The base class provides methods that developers can implement that are called as callbacks. They effectively replace the need for event listeners.

The base class provides `webSocketMessage(ws, message)`, `webSocketClose(ws, code, reason, wasClean)` and `webSocketError(ws , error)` ([API](https://developers.cloudflare.com/workers/runtime-apis/websockets)).

TypeScript

```
export class MyDurableObject extends DurableObject {  async fetch(request) {    // Creates two ends of a WebSocket connection.    const webSocketPair = new WebSocketPair();    const [client, server] = Object.values(webSocketPair);
    // Calling `acceptWebSocket()` connects the WebSocket to the Durable Object, allowing the WebSocket to send and receive messages.    this.ctx.acceptWebSocket(server);
    return new Response(null, {      status: 101,      webSocket: client,    });  }
  async webSocketMessage(ws, message) {    ws.send(message);  }}
```

### `alarm()`

HTTP and RPC requests are not the only entrypoints for a Durable Object. Alarms allow developers to schedule an event to trigger at a later time. Whenever the next alarm is due, the runtime will call the `alarm()` method, which is left to the developer to implement.

To schedule an alarm, you can use the `this.ctx.storage.setAlarm()` method. For more information, refer to [Alarms](https://developers.cloudflare.com/durable-objects/api/alarms/).

### `this.ctx`

The base `DurableObject` class sets the [DurableObjectState](https://developers.cloudflare.com/durable-objects/api/state/) into `this.ctx`. There are a lot of interesting methods and properties, but we will focus on `this.ctx.storage`.

### `this.ctx.storage`

[DurableObjectStorage](https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/) is the main interface with the Durable Object's persistence mechanisms, which include both a KV and SQLITE **synchronous** APIs.

TypeScript

```
const sql = this.ctx.storage.sql;
// Synchronous SQL queryconst rows = sql.exec("SELECT * FROM contacts WHERE country = ?", "US");
// Key-value storageconst token = this.ctx.storage.get("someToken");
```

### `this.ctx.env`

Lastly, it is worth mentioning that the Durable Object also has the Worker `Env` in `this.env`. Learn more in [Bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings).

## Layer 1: `Server` (partyserver)

Now that you have seen what Durable Objects provide out of the box, the `Server` class from [partyserver ↗](https://github.com/cloudflare/partykit/tree/main/packages/partyserver) will make more sense. It is an opinionated `DurableObject` wrapper that replaces low-level primitives with developer-friendly callbacks.

`Server` does not add any storage operations of its own — it only wraps the Durable Object lifecycle.

### Addressing

`partyserver` exposes helpers to address Durable Objects by name instead of going through bindings manually. This includes a URL routing scheme (`<your-worker>/servers/:durableClass/:durableName`) that the Agent layer builds on.

TypeScript

```
// Note the await here!const stub = await getServerByName(env.MY_DO, "foo");
// We can still call RPC methods.await stub.bar();
```

The URL scheme also enables a request router. In the Agent layer, this is re-exported as `routeAgentRequest`:

TypeScript

```
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {    const res = await routeAgentRequest(request, env);
    if (res) return res;
    return new Response("Not found", { status: 404 });  }
```

### `onStart`

The addressing layer allows `Server` to expose an `onStart` callback that runs every time the Durable Object starts up (after eviction, hibernation, or first creation) and before any `fetch` or RPC call.

TypeScript

```
class MyServer extends Server {  onStart() {    // Some initialization logic that you wish    // to run every time the DO is started up.    const sql = this.ctx.storage.sql;    sql.exec(`...`);  }}
```

### `onRequest` and `onConnect`

`Server` already implements `fetch` for the underlying Durable Object and exposes two different callbacks that developers can make use of, `onRequest` and `onConnect` for HTTP requests and incoming WS connections, respectively (WebSocket connections are accepted by default).

TypeScript

```
class MyServer extends Server {  async onRequest(request: Request) {    const url = new URL(request.url);
    return new Response(`Hello from ${url.origin}!`);  }
  async onConnect(conn, ctx) {    const { request } = ctx;    const url = new URL(request.url);
    // Connections are a WebSocket wrapper    conn.send(`Hello from ${url.origin}!`);  }}
```

### WebSockets

Just as `onConnect` is the callback for every new connection, `Server` also provides wrappers on top of the default callbacks from the `DurableObject` class: `onMessage`, `onClose` and `onError`.

There's also `this.broadcast` that sends a WS message to all connected clients (no magic, just a loop over `this.getConnections()`!).

### `this.name`

It is hard to get a Durable Object's `name` from within it. `partyserver` tries to make it available in `this.name` but it is not a perfect solution. Learn more about it in [this GitHub issue ↗](https://github.com/cloudflare/workerd/issues/2240).

## Layer 2: Agent

Now finally, the `Agent` class. `Agent` extends `Server` and provides opinionated primitives for stateful, schedulable, and observable agents that can communicate via RPC, WebSockets, and (even!) email.

### `this.state` and `this.setState()`

One of the core features of `Agent` is **automatic state persistence**. Developers define the shape of their state via the generic parameter and `initialState` (which is only used if no state exists in storage), and the Agent handles loading, saving, and broadcasting state changes (check `Server`'s `this.broadcast()` above).

`this.state` is a getter that lazily loads state from storage (SQL). State is persisted across Durable Object evictions when it is updated with `this.setState()`, which automatically serializes the state and writes it back to storage.

There's also `this.onStateChanged` that you can override to react to state changes.

TypeScript

```
class MyAgent extends Agent<Env, { count: number }> {  initialState = { count: 0 };
  increment() {    this.setState({ count: this.state.count + 1 });  }
  onStateChanged(state, source) {    console.log("State updated:", state);  }}
```

State is stored in the `cf_agents_state` SQL table. State messages are sent with `type: "cf_agent_state"` (both from the client and the server). Since `agents` provides [JS and React clients](https://developers.cloudflare.com/agents/runtime/lifecycle/state/#synchronizing-state), real-time state updates are available out of the box.

### `this.sql`

The Agent provides a convenient `sql` template tag for executing queries against the Durable Object's SQL storage. It constructs parameterized queries and executes them. This uses the **synchronous** SQL API from `this.ctx.storage.sql`.

TypeScript

```
class MyAgent extends Agent {  onStart() {    this.sql`      CREATE TABLE IF NOT EXISTS users (        id TEXT PRIMARY KEY,        name TEXT      )    `;
    const userId = "1";    const userName = "Alice";    this.sql`INSERT INTO users (id, name) VALUES (${userId}, ${userName})`;
    const users = this.sql<{ id: string; name: string }>`      SELECT * FROM users WHERE id = ${userId}    `;    console.log(users); // [{ id: "1", name: "Alice" }]  }}
```

### RPC and Callable Methods

`agents` takes Durable Objects RPC one step further by implementing RPC through WebSockets, so clients can call methods on the Agent directly. To make a method callable through WebSocket, use the `@callable()` decorator. Methods can return a serializable value or a stream (when using `@callable({ stream: true })`).

TypeScript

```
class MyAgent extends Agent {  @callable({ description: "Add two numbers" })  async add(a: number, b: number) {    return a + b;  }}
```

Clients can invoke this method by sending a WebSocket message:

```
{  "type": "rpc",  "id": "unique-request-id",  "method": "add",  "args": [2, 3]}
```

For example, with the provided `React` client, it is as easy as:

TypeScript

```
const { stub } = useAgent({ name: "my-agent" });const result = await stub.add(2, 3);console.log(result); // 5
```

### `this.queue` and friends

Agents include a built-in task queue for deferred execution. This is useful for offloading work or retrying operations. The available methods are `this.queue`, `this.dequeue`, `this.dequeueAll`, `this.dequeueAllByCallback`, `this.getQueue`, and `this.getQueues`.

TypeScript

```
class MyAgent extends Agent {  async onConnect() {    // Queue a task to be executed later    await this.queue("processTask", { userId: "123" });  }
  async processTask(payload: { userId: string }, queueItem: QueueItem) {    console.log("Processing task for user:", payload.userId);  }}
```

Tasks are stored in the `cf_agents_queues` SQL table and are automatically flushed in sequence. If a task succeeds, it is automatically dequeued.

### `this.schedule` and friends

Agents support scheduled execution of methods by wrapping the Durable Object's `alarm()`. The available methods are `this.schedule`, `this.getSchedule`, `this.getSchedules`, `this.cancelSchedule`. Schedules can be one-time, delayed, or recurring (using cron expressions).

Since Durable Objects only allow one alarm at a time, the `Agent` class works around this by managing multiple schedules in SQL and using a single alarm.

TypeScript

```
class MyAgent extends Agent {  async foo() {    // Schedule at a specific time    await this.schedule(new Date("2025-12-25T00:00:00Z"), "sendGreeting", {      message: "Merry Christmas!",    });
    // Schedule with a delay (in seconds)    await this.schedule(60, "checkStatus", { check: "health" });
    // Schedule with a cron expression    await this.schedule("0 0 * * *", "dailyTask", { type: "cleanup" });  }
  async sendGreeting(payload: { message: string }) {    console.log(payload.message);  }
  async checkStatus(payload: { check: string }) {    console.log("Running check:", payload.check);  }
  async dailyTask(payload: { type: string }) {    console.log("Daily task:", payload.type);  }}
```

Schedules are stored in the `cf_agents_schedules` SQL table. Cron schedules automatically reschedule themselves after execution, while one-time schedules are deleted.

### `this.mcp` and friends

`Agent` includes a multi-server MCP client. This enables your Agent to interact with external services that expose MCP interfaces. The MCP client is properly documented in [MCP client API](https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/).

TypeScript

```
class MyAgent extends Agent {  async onStart() {    // Add an HTTP MCP server (callbackHost only needed for OAuth servers)    await this.addMcpServer("GitHub", "https://mcp.github.com/mcp", {      callbackHost: "https://my-worker.example.workers.dev",    });
    // Add an MCP server via RPC (Durable Object binding, no HTTP overhead)    await this.addMcpServer("internal-tools", this.env.MyMCP);  }}
```

### Email Handling

Agents can receive and reply to emails using Cloudflare's [Email Routing](https://developers.cloudflare.com/email-service/api/route-emails/email-handler/).

TypeScript

```
class MyAgent extends Agent {  async onEmail(email: AgentEmail) {    console.log("Received email from:", email.from);    console.log("Subject:", email.headers.get("subject"));
    const raw = await email.getRaw();    console.log("Raw email size:", raw.length);
    // Reply to the email    await this.replyToEmail(email, {      fromName: "My Agent",      subject: "Re: " + email.headers.get("subject"),      body: "Thanks for your email!",      contentType: "text/plain",    });  }}
```

To route emails to your Agent, use `routeAgentEmail` in your Worker's email handler:

TypeScript

```
export default {  async email(message, env, ctx) {    await routeAgentEmail(message, env, {      resolver: createAddressBasedEmailResolver("my-agent"),    });  },} satisfies ExportedHandler<Env>;
```

### Context Management

`agents` wraps all your methods with an `AsyncLocalStorage` to maintain context throughout the request lifecycle. This allows you to access the current agent, connection, request, or email (depending on what event is being handled) from anywhere in your code:

TypeScript

```
import { getCurrentAgent } from "agents";
function someUtilityFunction() {  const { agent, connection, request, email } = getCurrentAgent();
  if (agent) {    console.log("Current agent:", agent.name);  }
  if (connection) {    console.log("WebSocket connection ID:", connection.id);  }}
```

### `this.onError`

`Agent` extends `Server`'s `onError` so it can be used to handle errors that are not necessarily WebSocket errors. It is called with a `Connection` or `unknown` error.

TypeScript

```
class MyAgent extends Agent {  onError(connectionOrError: Connection | unknown, error?: unknown) {    if (error) {      // WebSocket connection error      console.error("Connection error:", error);    } else {      // Server error      console.error("Server error:", connectionOrError);    }
    // Optionally throw to propagate the error    throw connectionOrError;  }}
```

### `this.destroy`

`this.destroy()` drops all tables, deletes alarms, clears storage, and aborts the context. To ensure that the Durable Object is fully evicted, `this.ctx.abort()` is called asynchronously using `setTimeout()` to allow any currently executing handlers (like scheduled tasks) to complete their cleanup operations before the context is aborted.

This means `this.ctx.abort()` throws an uncatchable error that will show up in your logs, but it does so after yielding to the event loop (read more about it in [abort()](https://developers.cloudflare.com/durable-objects/api/state/#abort)).

The `destroy()` method can be safely called within scheduled tasks. When called from within a schedule callback, the Agent sets an internal flag to skip any remaining database updates, and yields `ctx.abort()` to the event loop to ensure the alarm handler completes cleanly before the Agent is evicted.

TypeScript

```
class MyAgent extends Agent {  async onStart() {    console.log("Agent is starting up...");    // Initialize your agent  }
  async cleanup() {    // This wipes everything!    await this.destroy();  }
  async selfDestruct() {    // Safe to call from within a scheduled task    await this.schedule(60, "destroyAfterDelay", {});  }
  async destroyAfterDelay() {    // This will safely destroy the Agent even when    // called from within the alarm handler    await this.destroy();  }}
```

Using destroy() in scheduled tasks

You can safely call `this.destroy()` from within a scheduled task callback. The Agent SDK sets an internal flag to prevent database updates after destruction and defers the context abort to ensure the alarm handler completes cleanly.

### `static options`

Configure agent behavior by overriding `static options` on your class. All fields are optional — defaults are applied at runtime.

TypeScript

```
export class MyAgent extends Agent {  static options = {    hibernate: true,    sendIdentityOnConnect: false,    retry: { maxAttempts: 5, baseDelayMs: 200, maxDelayMs: 5000 },  };}
```

| Option                     | Type         | Default                                                | Description                                                                                                              |
| -------------------------- | ------------ | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| hibernate                  | boolean      | true                                                   | Whether the agent hibernates when inactive. WebSocket connections stay open while the DO sleeps                          |
| sendIdentityOnConnect      | boolean      | true                                                   | Send identity (agent name, instance name) to clients on WebSocket connect. Set to false to hide sensitive instance names |
| hungScheduleTimeoutSeconds | number       | 30                                                     | Timeout before a running interval schedule is considered hung and force-reset. Increase for long-running callbacks       |
| keepAliveIntervalMs        | number       | 30000                                                  | Interval in milliseconds for keepAlive() alarm heartbeats. Lower values mean faster recovery but more frequent alarms    |
| retry                      | RetryOptions | { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 3000 } | Default retry options for schedule(), queue(), and this.retry(). Per-task options override these defaults                |

### `this.keepAlive()` and `this.keepAliveWhile()`

Durable Objects are evicted after a period of inactivity (typically 70–140 seconds with no incoming requests, WebSocket messages, or alarms). During long-running operations — streaming LLM responses, waiting on external APIs, running multi-step computations — the agent can be evicted mid-flight.

`keepAlive()` creates an alarm heartbeat that prevents eviction. `keepAliveWhile()` wraps an async function and guarantees cleanup.

TypeScript

```
class MyAgent extends Agent {  async handleLongTask() {    // Option 1: manual dispose    const dispose = await this.keepAlive();    try {      await longRunningComputation();    } finally {      dispose();    }
    // Option 2: automatic cleanup (recommended)    const result = await this.keepAliveWhile(async () => {      return await longRunningComputation();    });  }}
```

`AIChatAgent` uses `keepAliveWhile` internally to keep the agent alive during streaming LLM responses. For more details, refer to [Schedule tasks — Keeping the agent alive](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/#keeping-the-agent-alive).

### Routing

The `Agent` class re-exports the [addressing helpers](#addressing) as `getAgentByName` and `routeAgentRequest`.

TypeScript

```
const stub = await getAgentByName(env.MY_DO, "foo");await stub.someMethod();
const res = await routeAgentRequest(request, env);if (res) return res;
return new Response("Not found", { status: 404 });
```

## Layer 3: `AIChatAgent`

The [AIChatAgent](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/) class from `@cloudflare/ai-chat` extends `Agent` with an opinionated layer for AI chat. It adds automatic message persistence to SQLite, resumable streaming, tool support (server-side, client-side, and human-in-the-loop), and a React hook (`useAgentChat`) for building chat UIs.

The full hierarchy is: **DurableObject** \> **Server** \> **Agent** \> **AIChatAgent**.

If you are building a chat agent, start with `AIChatAgent`. If you need lower-level control or are not building a chat interface, use `Agent` directly.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/lifecycle/agent-class/#page","headline":"Agent class internals · Cloudflare Agents docs","description":"Explore how the Agent class extends Durable Objects to provide state, WebSockets, scheduling, and RPC.","url":"https://developers.cloudflare.com/agents/runtime/lifecycle/agent-class/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-09","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/lifecycle/","name":"Lifecycle"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/lifecycle/agent-class/","name":"Agent class internals"}}]}
```

---

---
title: Callable methods
description: Expose Agent methods to external clients over WebSocket RPC using the @callable() decorator.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Callable methods

Callable methods let clients invoke agent methods over WebSocket using RPC (Remote Procedure Call). Mark methods with `@callable()` to expose them to external clients like browsers, mobile apps, or other services.

## Overview

* [  JavaScript ](#tab-panel-6469)
* [  TypeScript ](#tab-panel-6470)

JavaScript

```
import { Agent, callable } from "agents";
export class MyAgent extends Agent {  @callable()  async greet(name) {    return `Hello, ${name}!`;  }}
```

TypeScript

```
import { Agent, callable } from "agents";
export class MyAgent extends Agent {  @callable()  async greet(name: string): Promise<string> {    return `Hello, ${name}!`;  }}
```

* [  JavaScript ](#tab-panel-6467)
* [  TypeScript ](#tab-panel-6468)

JavaScript

```
// Clientconst result = await agent.stub.greet("World");console.log(result); // "Hello, World!"
```

TypeScript

```
// Clientconst result = await agent.stub.greet("World");console.log(result); // "Hello, World!"
```

### How it works

sequenceDiagram
    participant Client
    participant Agent
    Client->>Agent: agent.stub.greet("World")
    Note right of Agent: Check @callable<br/>Execute method
    Agent-->>Client: "Hello, World!"

### When to use `@callable()`

| Scenario                             | Use                                      |
| ------------------------------------ | ---------------------------------------- |
| Browser/mobile calling agent         | @callable()                              |
| External service calling agent       | @callable()                              |
| Worker calling agent (same codebase) | Durable Object RPC (no decorator needed) |
| Agent calling another agent          | Durable Object RPC via getAgentByName()  |

The `@callable()` decorator is specifically for WebSocket-based RPC from external clients. When calling from within the same Worker or another agent, use standard [Durable Object RPC](https://developers.cloudflare.com/durable-objects/best-practices/create-durable-object-stubs-and-send-requests/) directly.

## Basic usage

### Defining callable methods

Add the `@callable()` decorator to any method you want to expose:

* [  JavaScript ](#tab-panel-6487)
* [  TypeScript ](#tab-panel-6488)

JavaScript

```
import { Agent, callable } from "agents";
export class CounterAgent extends Agent {  initialState = { count: 0, items: [] };
  @callable()  increment() {    this.setState({ ...this.state, count: this.state.count + 1 });    return this.state.count;  }
  @callable()  decrement() {    this.setState({ ...this.state, count: this.state.count - 1 });    return this.state.count;  }
  @callable()  async addItem(item) {    this.setState({ ...this.state, items: [...this.state.items, item] });    return this.state.items;  }
  @callable()  getStats() {    return {      count: this.state.count,      itemCount: this.state.items.length,    };  }}
```

TypeScript

```
import { Agent, callable } from "agents";
export type CounterState = {  count: number;  items: string[];};
export class CounterAgent extends Agent<Env, CounterState> {  initialState: CounterState = { count: 0, items: [] };
  @callable()  increment(): number {    this.setState({ ...this.state, count: this.state.count + 1 });    return this.state.count;  }
  @callable()  decrement(): number {    this.setState({ ...this.state, count: this.state.count - 1 });    return this.state.count;  }
  @callable()  async addItem(item: string): Promise<string[]> {    this.setState({ ...this.state, items: [...this.state.items, item] });    return this.state.items;  }
  @callable()  getStats(): { count: number; itemCount: number } {    return {      count: this.state.count,      itemCount: this.state.items.length,    };  }}
```

### Calling from the client

There are two ways to call methods from the client:

#### Using `agent.stub` (recommended):

* [  JavaScript ](#tab-panel-6471)
* [  TypeScript ](#tab-panel-6472)

JavaScript

```
// Clean, typed syntaxconst count = await agent.stub.increment();const items = await agent.stub.addItem("new item");const stats = await agent.stub.getStats();
```

TypeScript

```
// Clean, typed syntaxconst count = await agent.stub.increment();const items = await agent.stub.addItem("new item");const stats = await agent.stub.getStats();
```

#### Using `agent.call()`:

* [  JavaScript ](#tab-panel-6473)
* [  TypeScript ](#tab-panel-6474)

JavaScript

```
// Explicit method name as stringconst count = await agent.call("increment");const items = await agent.call("addItem", ["new item"]);const stats = await agent.call("getStats");
```

TypeScript

```
// Explicit method name as stringconst count = await agent.call("increment");const items = await agent.call("addItem", ["new item"]);const stats = await agent.call("getStats");
```

The `stub` proxy provides better ergonomics and TypeScript support.

## Method signatures

### Serializable types

Arguments and return values must be JSON-serializable:

* [  JavaScript ](#tab-panel-6479)
* [  TypeScript ](#tab-panel-6480)

JavaScript

```
// Valid - primitives and plain objectsclass MyAgent extends Agent {  @callable()  processData(input) {    return { result: true };  }}
// Valid - arraysclass MyAgent extends Agent {  @callable()  processItems(items) {    return items.map((item) => item.length);  }}
// Invalid - non-serializable types// Functions, Dates, Maps, Sets, etc. cannot be serialized
```

TypeScript

```
// Valid - primitives and plain objectsclass MyAgent extends Agent {  @callable()  processData(input: { name: string; count: number }): { result: boolean } {    return { result: true };  }}
// Valid - arraysclass MyAgent extends Agent {  @callable()  processItems(items: string[]): number[] {    return items.map((item) => item.length);  }}
// Invalid - non-serializable types// Functions, Dates, Maps, Sets, etc. cannot be serialized
```

### Async methods

Both sync and async methods work:

* [  JavaScript ](#tab-panel-6481)
* [  TypeScript ](#tab-panel-6482)

JavaScript

```
// Sync methodclass MyAgent extends Agent {  @callable()  add(a, b) {    return a + b;  }}
// Async methodclass MyAgent extends Agent {  @callable()  async fetchUser(id) {    const user = await this.sql`SELECT * FROM users WHERE id = ${id}`;    return user[0];  }}
```

TypeScript

```
// Sync methodclass MyAgent extends Agent {  @callable()  add(a: number, b: number): number {    return a + b;  }}
// Async methodclass MyAgent extends Agent {  @callable()  async fetchUser(id: string): Promise<User> {    const user = await this.sql`SELECT * FROM users WHERE id = ${id}`;    return user[0];  }}
```

### Void methods

Methods that do not return a value:

* [  JavaScript ](#tab-panel-6477)
* [  TypeScript ](#tab-panel-6478)

JavaScript

```
class MyAgent extends Agent {  @callable()  async logEvent(event) {    await this.sql`INSERT INTO events (name) VALUES (${event})`;  }}
```

TypeScript

```
class MyAgent extends Agent {  @callable()  async logEvent(event: string): Promise<void> {    await this.sql`INSERT INTO events (name) VALUES (${event})`;  }}
```

On the client, these still return a Promise that resolves when the method completes:

* [  JavaScript ](#tab-panel-6475)
* [  TypeScript ](#tab-panel-6476)

JavaScript

```
await agent.stub.logEvent("user-clicked");// Resolves when the server confirms execution
```

TypeScript

```
await agent.stub.logEvent("user-clicked");// Resolves when the server confirms execution
```

## Streaming responses

For methods that produce data over time (like AI text generation), use streaming:

### Defining a streaming method

* [  JavaScript ](#tab-panel-6489)
* [  TypeScript ](#tab-panel-6490)

JavaScript

```
import { Agent, callable } from "agents";
export class AIAgent extends Agent {  @callable({ streaming: true })  async generateText(stream, prompt) {    // First parameter is always StreamingResponse for streaming methods
    for await (const chunk of this.llm.stream(prompt)) {      stream.send(chunk); // Send each chunk to the client    }
    stream.end(); // Signal completion  }
  @callable({ streaming: true })  async streamNumbers(stream, count) {    for (let i = 0; i < count; i++) {      stream.send(i);      await new Promise((resolve) => setTimeout(resolve, 100));    }    stream.end(count); // Optional final value  }}
```

TypeScript

```
import { Agent, callable, type StreamingResponse } from "agents";
export class AIAgent extends Agent {  @callable({ streaming: true })  async generateText(stream: StreamingResponse, prompt: string) {    // First parameter is always StreamingResponse for streaming methods
    for await (const chunk of this.llm.stream(prompt)) {      stream.send(chunk); // Send each chunk to the client    }
    stream.end(); // Signal completion  }
  @callable({ streaming: true })  async streamNumbers(stream: StreamingResponse, count: number) {    for (let i = 0; i < count; i++) {      stream.send(i);      await new Promise((resolve) => setTimeout(resolve, 100));    }    stream.end(count); // Optional final value  }}
```

### Consuming streams on the client

* [  JavaScript ](#tab-panel-6497)
* [  TypeScript ](#tab-panel-6498)

JavaScript

```
// Preferred format (supports timeout and other options)await agent.call("generateText", [prompt], {  stream: {    onChunk: (chunk) => {      // Called for each chunk      appendToOutput(chunk);    },    onDone: (finalValue) => {      // Called when stream ends      console.log("Stream complete", finalValue);    },    onError: (error) => {      // Called if an error occurs      console.error("Stream error:", error);    },  },});
// Legacy format (still supported for backward compatibility)await agent.call("generateText", [prompt], {  onChunk: (chunk) => appendToOutput(chunk),  onDone: (finalValue) => console.log("Done", finalValue),  onError: (error) => console.error("Error:", error),});
```

TypeScript

```
// Preferred format (supports timeout and other options)await agent.call("generateText", [prompt], {  stream: {    onChunk: (chunk) => {      // Called for each chunk      appendToOutput(chunk);    },    onDone: (finalValue) => {      // Called when stream ends      console.log("Stream complete", finalValue);    },    onError: (error) => {      // Called if an error occurs      console.error("Stream error:", error);    },  },});
// Legacy format (still supported for backward compatibility)await agent.call("generateText", [prompt], {  onChunk: (chunk) => appendToOutput(chunk),  onDone: (finalValue) => console.log("Done", finalValue),  onError: (error) => console.error("Error:", error),});
```

### StreamingResponse API

| Method           | Description                                      |
| ---------------- | ------------------------------------------------ |
| send(chunk)      | Send a chunk to the client                       |
| end(finalChunk?) | End the stream, optionally with a final value    |
| error(message)   | Send an error to the client and close the stream |

* [  JavaScript ](#tab-panel-6483)
* [  TypeScript ](#tab-panel-6484)

JavaScript

```
class MyAgent extends Agent {  @callable({ streaming: true })  async processWithProgress(stream, items) {    for (let i = 0; i < items.length; i++) {      await this.process(items[i]);      stream.send({ progress: (i + 1) / items.length, item: items[i] });    }    stream.end({ completed: true, total: items.length });  }}
```

TypeScript

```
class MyAgent extends Agent {  @callable({ streaming: true })  async processWithProgress(stream: StreamingResponse, items: string[]) {    for (let i = 0; i < items.length; i++) {      await this.process(items[i]);      stream.send({ progress: (i + 1) / items.length, item: items[i] });    }    stream.end({ completed: true, total: items.length });  }}
```

## TypeScript integration

### Typed client calls

Pass your agent class as a type parameter for full type safety:

* [  JavaScript ](#tab-panel-6495)
* [  TypeScript ](#tab-panel-6496)

JavaScript

```
import { useAgent } from "agents/react";
function App() {  const agent = useAgent({    agent: "MyAgent",    name: "default",  });
  async function handleGreet() {    // TypeScript knows the method signature    const result = await agent.stub.greet("World");    // ^? string  }
  // TypeScript catches errors  // await agent.stub.greet(123); // Error: Argument of type 'number' is not assignable  // await agent.stub.nonExistent(); // Error: Property 'nonExistent' does not exist}
```

TypeScript

```
import { useAgent } from "agents/react";import type { MyAgent } from "./server";
function App() {  const agent = useAgent<MyAgent>({    agent: "MyAgent",    name: "default",  });
  async function handleGreet() {    // TypeScript knows the method signature    const result = await agent.stub.greet("World");    // ^? string  }
  // TypeScript catches errors  // await agent.stub.greet(123); // Error: Argument of type 'number' is not assignable  // await agent.stub.nonExistent(); // Error: Property 'nonExistent' does not exist}
```

### Excluding non-callable methods

If you have methods that are not decorated with `@callable()`, you can exclude them from the type:

* [  JavaScript ](#tab-panel-6501)
* [  TypeScript ](#tab-panel-6502)

JavaScript

```
class MyAgent extends Agent {  @callable()  publicMethod() {    return "public";  }
  // Not callable from clients  internalMethod() {    // internal logic  }}
// Exclude internal methods from the client typeconst agent = useAgent({  agent: "MyAgent",});
agent.stub.publicMethod(); // Works// agent.stub.internalMethod(); // TypeScript error
```

TypeScript

```
class MyAgent extends Agent {  @callable()  publicMethod(): string {    return "public";  }
  // Not callable from clients  internalMethod(): void {    // internal logic  }}
// Exclude internal methods from the client typeconst agent = useAgent<Omit<MyAgent, "internalMethod">>({  agent: "MyAgent",});
agent.stub.publicMethod(); // Works// agent.stub.internalMethod(); // TypeScript error
```

## Error handling

### Throwing errors in callable methods

Errors thrown in callable methods are propagated to the client:

* [  JavaScript ](#tab-panel-6491)
* [  TypeScript ](#tab-panel-6492)

JavaScript

```
class MyAgent extends Agent {  @callable()  async riskyOperation(data) {    if (!isValid(data)) {      throw new Error("Invalid data format");    }
    try {      await this.processData(data);    } catch (e) {      throw new Error("Processing failed: " + e.message);    }  }}
```

TypeScript

```
class MyAgent extends Agent {  @callable()  async riskyOperation(data: unknown): Promise<void> {    if (!isValid(data)) {      throw new Error("Invalid data format");    }
    try {      await this.processData(data);    } catch (e) {      throw new Error("Processing failed: " + e.message);    }  }}
```

### Client-side error handling

* [  JavaScript ](#tab-panel-6485)
* [  TypeScript ](#tab-panel-6486)

JavaScript

```
try {  const result = await agent.stub.riskyOperation(data);} catch (error) {  // Error thrown by the agent method  console.error("RPC failed:", error.message);}
```

TypeScript

```
try {  const result = await agent.stub.riskyOperation(data);} catch (error) {  // Error thrown by the agent method  console.error("RPC failed:", error.message);}
```

### Streaming error handling

For streaming methods, use the `onError` callback:

* [  JavaScript ](#tab-panel-6493)
* [  TypeScript ](#tab-panel-6494)

JavaScript

```
await agent.call("streamData", [input], {  stream: {    onChunk: (chunk) => handleChunk(chunk),    onError: (errorMessage) => {      console.error("Stream error:", errorMessage);      showErrorUI(errorMessage);    },    onDone: (result) => handleComplete(result),  },});
```

TypeScript

```
await agent.call("streamData", [input], {  stream: {    onChunk: (chunk) => handleChunk(chunk),    onError: (errorMessage) => {      console.error("Stream error:", errorMessage);      showErrorUI(errorMessage);    },    onDone: (result) => handleComplete(result),  },});
```

Server-side, you can use `stream.error()` to gracefully send an error mid-stream:

* [  JavaScript ](#tab-panel-6503)
* [  TypeScript ](#tab-panel-6504)

JavaScript

```
class MyAgent extends Agent {  @callable({ streaming: true })  async processItems(stream, items) {    for (const item of items) {      try {        const result = await this.process(item);        stream.send(result);      } catch (e) {        stream.error(`Failed to process ${item}: ${e.message}`);        return; // Stream is now closed      }    }    stream.end();  }}
```

TypeScript

```
class MyAgent extends Agent {  @callable({ streaming: true })  async processItems(stream: StreamingResponse, items: string[]) {    for (const item of items) {      try {        const result = await this.process(item);        stream.send(result);      } catch (e) {        stream.error(`Failed to process ${item}: ${e.message}`);        return; // Stream is now closed      }    }    stream.end();  }}
```

### Connection errors

If the WebSocket connection closes while RPC calls are pending, they automatically reject with a "Connection closed" error:

* [  JavaScript ](#tab-panel-6499)
* [  TypeScript ](#tab-panel-6500)

JavaScript

```
try {  const result = await agent.call("longRunningMethod", []);} catch (error) {  if (error.message === "Connection closed") {    // Handle disconnection    console.log("Lost connection to agent");  }}
```

TypeScript

```
try {  const result = await agent.call("longRunningMethod", []);} catch (error) {  if (error.message === "Connection closed") {    // Handle disconnection    console.log("Lost connection to agent");  }}
```

#### Retrying after reconnection

The client automatically reconnects after disconnection. To retry a failed call after reconnection, await `agent.ready` before retrying:

* [  JavaScript ](#tab-panel-6509)
* [  TypeScript ](#tab-panel-6510)

JavaScript

```
async function callWithRetry(agent, method, args = []) {  try {    return await agent.call(method, args);  } catch (error) {    if (error.message === "Connection closed") {      await agent.ready; // Wait for reconnection      return await agent.call(method, args); // Retry once    }    throw error;  }}
// Usageconst result = await callWithRetry(agent, "processData", [data]);
```

TypeScript

```
async function callWithRetry<T>(  agent: AgentClient,  method: string,  args: unknown[] = [],): Promise<T> {  try {    return await agent.call(method, args);  } catch (error) {    if (error.message === "Connection closed") {      await agent.ready; // Wait for reconnection      return await agent.call(method, args); // Retry once    }    throw error;  }}
// Usageconst result = await callWithRetry(agent, "processData", [data]);
```

Note

Only retry idempotent operations. If the server received the request but the connection dropped before the response arrived, retrying could cause duplicate execution.

## When NOT to use @callable

### Worker-to-Agent calls

When calling an agent from the same Worker (for example, in your `fetch` handler), use Durable Object RPC directly:

* [  JavaScript ](#tab-panel-6505)
* [  TypeScript ](#tab-panel-6506)

JavaScript

```
import { getAgentByName } from "agents";
export default {  async fetch(request, env) {    // Get the agent stub    const agent = await getAgentByName(env.MyAgent, "instance-name");
    // Call methods directly - no @callable needed    const result = await agent.processData(data);
    return Response.json(result);  },};
```

TypeScript

```
import { getAgentByName } from "agents";
export default {  async fetch(request: Request, env: Env) {    // Get the agent stub    const agent = await getAgentByName(env.MyAgent, "instance-name");
    // Call methods directly - no @callable needed    const result = await agent.processData(data);
    return Response.json(result);  },} satisfies ExportedHandler<Env>;
```

### Agent-to-Agent calls

When one agent needs to call another:

* [  JavaScript ](#tab-panel-6507)
* [  TypeScript ](#tab-panel-6508)

JavaScript

```
class OrchestratorAgent extends Agent {  async delegateWork(taskId) {    // Get another agent    const worker = await getAgentByName(this.env.WorkerAgent, taskId);
    // Call its methods directly    const result = await worker.doWork();
    return result;  }}
```

TypeScript

```
class OrchestratorAgent extends Agent {  async delegateWork(taskId: string) {    // Get another agent    const worker = await getAgentByName(this.env.WorkerAgent, taskId);
    // Call its methods directly    const result = await worker.doWork();
    return result;  }}
```

### Why the distinction?

| RPC Type           | Transport | Use Case                          |
| ------------------ | --------- | --------------------------------- |
| @callable          | WebSocket | External clients (browsers, apps) |
| Durable Object RPC | Internal  | Worker to Agent, Agent to Agent   |

Durable Object RPC is more efficient for internal calls since it does not go through WebSocket serialization. The `@callable` decorator adds the necessary WebSocket RPC handling for external clients.

## API reference

### @callable(metadata?) decorator

Marks a method as callable from external clients.

* [  JavaScript ](#tab-panel-6511)
* [  TypeScript ](#tab-panel-6512)

JavaScript

```
import { callable } from "agents";
class MyAgent extends Agent {  @callable()  method() {}
  @callable({ streaming: true })  streamingMethod(stream) {}
  @callable({ description: "Fetches user data" })  getUser(id) {}}
```

TypeScript

```
import { callable } from "agents";
class MyAgent extends Agent {  @callable()  method(): void {}
  @callable({ streaming: true })  streamingMethod(stream: StreamingResponse): void {}
  @callable({ description: "Fetches user data" })  getUser(id: string): User {}}
```

### CallableMetadata type

TypeScript

```
type CallableMetadata = {  /** Optional description of what the method does */  description?: string;  /** Whether the method supports streaming responses */  streaming?: boolean;};
```

### StreamingResponse class

Used in streaming callable methods to send data to the client.

* [  JavaScript ](#tab-panel-6513)
* [  TypeScript ](#tab-panel-6514)

JavaScript

```
import {} from "agents";
class MyAgent extends Agent {  @callable({ streaming: true })  async streamData(stream, input) {    stream.send("chunk 1");    stream.send("chunk 2");    stream.end("final");  }}
```

TypeScript

```
import { type StreamingResponse } from "agents";
class MyAgent extends Agent {  @callable({ streaming: true })  async streamData(stream: StreamingResponse, input: string) {    stream.send("chunk 1");    stream.send("chunk 2");    stream.end("final");  }}
```

| Method | Signature                      | Description                        |
| ------ | ------------------------------ | ---------------------------------- |
| send   | (chunk: unknown) => void       | Send a chunk to the client         |
| end    | (finalChunk?: unknown) => void | End the stream                     |
| error  | (message: string) => void      | Send an error and close the stream |

### Client methods

| Method     | Signature                            | Description           |
| ---------- | ------------------------------------ | --------------------- |
| agent.call | (method, args?, options?) => Promise | Call a method by name |
| agent.stub | Proxy                                | Typed method calls    |

* [  JavaScript ](#tab-panel-6517)
* [  TypeScript ](#tab-panel-6518)

JavaScript

```
// Using call()await agent.call("methodName", [arg1, arg2]);await agent.call("streamMethod", [arg], {  stream: { onChunk, onDone, onError },});
// With timeout (rejects if call does not complete in time)await agent.call("slowMethod", [], { timeout: 5000 });
// Using stubawait agent.stub.methodName(arg1, arg2);
```

TypeScript

```
// Using call()await agent.call("methodName", [arg1, arg2]);await agent.call("streamMethod", [arg], {  stream: { onChunk, onDone, onError },});
// With timeout (rejects if call does not complete in time)await agent.call("slowMethod", [], { timeout: 5000 });
// Using stubawait agent.stub.methodName(arg1, arg2);
```

### CallOptions type

TypeScript

```
type CallOptions = {  /** Timeout in milliseconds. Rejects if call does not complete in time. */  timeout?: number;  /** Streaming options */  stream?: {    onChunk?: (chunk: unknown) => void;    onDone?: (finalChunk: unknown) => void;    onError?: (error: string) => void;  };};
```

Note

The legacy format `{ onChunk, onDone, onError }` (without nesting under `stream`) is still supported. The client automatically detects which format you are using.

### getCallableMethods() method

Returns a map of all callable methods on the agent with their metadata. Useful for introspection and automatic documentation.

* [  JavaScript ](#tab-panel-6515)
* [  TypeScript ](#tab-panel-6516)

JavaScript

```
const methods = agent.getCallableMethods();// Map<string, CallableMetadata>
for (const [name, meta] of methods) {  console.log(`${name}: ${meta.description || "(no description)"}`);  if (meta.streaming) console.log("  (streaming)");}
```

TypeScript

```
const methods = agent.getCallableMethods();// Map<string, CallableMetadata>
for (const [name, meta] of methods) {  console.log(`${name}: ${meta.description || "(no description)"}`);  if (meta.streaming) console.log("  (streaming)");}
```

## Troubleshooting

### `SyntaxError: Invalid or unexpected token`

If your dev server fails with `SyntaxError: Invalid or unexpected token` when using `@callable()`, you need two things:

**1\. Add the `agents/vite` plugin** — Vite 8 uses Oxc for transpilation, which does not yet support TC39 decorators. The plugin adds the required transform:

vite.config.ts

```
import agents from "agents/vite";
export default defineConfig({  plugins: [agents(), react(), cloudflare()],});
```

**2\. Extend `agents/tsconfig`** — this sets `"target": "ES2021"` and all other recommended compiler options:

tsconfig.json

```
{  "extends": "agents/tsconfig"}
```

If you cannot extend the shared config, set `"target": "ES2021"` manually in your `tsconfig.json`.

Warning

Do not set `"experimentalDecorators": true` in your `tsconfig.json`. The Agents SDK uses [TC39 standard decorators ↗](https://github.com/tc39/proposal-decorators), not TypeScript legacy decorators. Enabling `experimentalDecorators` applies an incompatible transform that silently breaks `@callable()` at runtime.

## Next steps

[ Agents API ](https://developers.cloudflare.com/agents/runtime/agents-api/) Complete API reference for the Agents SDK. 

[ WebSockets ](https://developers.cloudflare.com/agents/runtime/communication/websockets/) Real-time bidirectional communication with clients. 

[ State management ](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) Sync state between agents and clients.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/#page","headline":"Callable methods · Cloudflare Agents docs","description":"Expose Agent methods to external clients over WebSocket RPC using the @callable() decorator.","url":"https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/lifecycle/","name":"Lifecycle"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/lifecycle/callable-methods/","name":"Callable methods"}}]}
```

---

---
title: getCurrentAgent()
description: Access the current Agent context from external utility functions using getCurrentAgent() in the Agents SDK.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# getCurrentAgent()

The `getCurrentAgent()` function allows you to access the current agent context from anywhere in your code, including external utility functions and libraries. This is useful when you need agent information in functions that do not have direct access to `this`.

## Automatic context for custom methods

The framework detects and wraps custom Agent methods during initialization so `getCurrentAgent()` can resolve the active agent inside them and the functions they call.

## How it works

* [  JavaScript ](#tab-panel-6521)
* [  TypeScript ](#tab-panel-6522)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { getCurrentAgent } from "agents";
export class MyAgent extends AIChatAgent {  async customMethod() {    const { agent } = getCurrentAgent();    // agent is automatically available    console.log(agent.name);  }
  async anotherMethod() {    // This works too - no setup needed    const { agent } = getCurrentAgent();    return agent.state;  }}
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { getCurrentAgent } from "agents";
export class MyAgent extends AIChatAgent {  async customMethod() {    const { agent } = getCurrentAgent();    // agent is automatically available    console.log(agent.name);  }
  async anotherMethod() {    // This works too - no setup needed    const { agent } = getCurrentAgent();    return agent.state;  }}
```

No configuration is required. The framework automatically:

1. Scans your agent class for custom methods.
2. Wraps them with agent context during initialization.
3. Ensures `getCurrentAgent()` works in all external functions called from your methods.

## Real-world example

* [  JavaScript ](#tab-panel-6537)
* [  TypeScript ](#tab-panel-6538)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { getCurrentAgent } from "agents";import { generateText } from "ai";import { openai } from "@ai-sdk/openai";
// External utility function that needs agent contextasync function processWithAI(prompt) {  const { agent } = getCurrentAgent();  // External functions can access the current agent
  return await generateText({    model: openai("gpt-4"),    prompt: `Agent ${agent?.name}: ${prompt}`,  });}
export class MyAgent extends AIChatAgent {  async customMethod(message) {    // Use this.* to access agent properties directly    console.log("Agent name:", this.name);    console.log("Agent state:", this.state);
    // External functions automatically work    const result = await processWithAI(message);    return result.text;  }}
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { getCurrentAgent } from "agents";import { generateText } from "ai";import { openai } from "@ai-sdk/openai";
// External utility function that needs agent contextasync function processWithAI(prompt: string) {  const { agent } = getCurrentAgent();  // External functions can access the current agent
  return await generateText({    model: openai("gpt-4"),    prompt: `Agent ${agent?.name}: ${prompt}`,  });}
export class MyAgent extends AIChatAgent {  async customMethod(message: string) {    // Use this.* to access agent properties directly    console.log("Agent name:", this.name);    console.log("Agent state:", this.state);
    // External functions automatically work    const result = await processWithAI(message);    return result.text;  }}
```

### Built-in vs custom methods

* **Built-in methods** (`onRequest`, `onEmail`, `onStateChanged`): Already have context.
* **Custom methods** (your methods): Automatically wrapped during initialization.
* **External functions**: Access context through `getCurrentAgent()`.

### The context flow

* [  JavaScript ](#tab-panel-6519)
* [  TypeScript ](#tab-panel-6520)

JavaScript

```
// When you call a custom method:agent.customMethod();// → automatically wrapped with agentContext.run()// → your method executes with full context// → external functions can use getCurrentAgent()
```

TypeScript

```
// When you call a custom method:agent.customMethod();// → automatically wrapped with agentContext.run()// → your method executes with full context// → external functions can use getCurrentAgent()
```

## Common use cases

### Working with AI SDK tools

* [  JavaScript ](#tab-panel-6531)
* [  TypeScript ](#tab-panel-6532)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { generateText } from "ai";import { openai } from "@ai-sdk/openai";
export class MyAgent extends AIChatAgent {  async generateResponse(prompt) {    // AI SDK tools automatically work    const response = await generateText({      model: openai("gpt-4"),      prompt,      tools: {        // Tools that use getCurrentAgent() work perfectly      },    });
    return response.text;  }}
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { generateText } from "ai";import { openai } from "@ai-sdk/openai";
export class MyAgent extends AIChatAgent {  async generateResponse(prompt: string) {    // AI SDK tools automatically work    const response = await generateText({      model: openai("gpt-4"),      prompt,      tools: {        // Tools that use getCurrentAgent() work perfectly      },    });
    return response.text;  }}
```

### Calling external libraries

* [  JavaScript ](#tab-panel-6527)
* [  TypeScript ](#tab-panel-6528)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { getCurrentAgent } from "agents";
async function saveToDatabase(data) {  const { agent } = getCurrentAgent();  // Can access agent info for logging, context, etc.  console.log(`Saving data for agent: ${agent?.name}`);}
export class MyAgent extends AIChatAgent {  async processData(data) {    // External functions automatically have context    await saveToDatabase(data);  }}
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { getCurrentAgent } from "agents";
async function saveToDatabase(data: any) {  const { agent } = getCurrentAgent();  // Can access agent info for logging, context, etc.  console.log(`Saving data for agent: ${agent?.name}`);}
export class MyAgent extends AIChatAgent {  async processData(data: any) {    // External functions automatically have context    await saveToDatabase(data);  }}
```

### Accessing request and connection context

* [  JavaScript ](#tab-panel-6533)
* [  TypeScript ](#tab-panel-6534)

JavaScript

```
import { getCurrentAgent } from "agents";
function logRequestInfo() {  const { agent, connection, request } = getCurrentAgent();
  if (request) {    console.log("Request URL:", request.url);    console.log("Request method:", request.method);  }
  if (connection) {    console.log("Connection ID:", connection.id);  }}
```

TypeScript

```
import { getCurrentAgent } from "agents";
function logRequestInfo() {  const { agent, connection, request } = getCurrentAgent();
  if (request) {    console.log("Request URL:", request.url);    console.log("Request method:", request.method);  }
  if (connection) {    console.log("Connection ID:", connection.id);  }}
```

## When context is lost

The agent context only propagates along the call tree of the original invocation. Code reached outside that call tree starts with an empty context, so `getCurrentAgent()` returns an object whose fields are `undefined`. Common cases include:

* a host callback invoked through RPC from a Worker Loader child isolate, such as sandboxed Codemode execution;
* a service binding or Durable Object RPC entrypoint;
* a queue consumer or another entrypoint that retains an agent reference.

Route the callback through a public method on the agent. Custom methods are wrapped automatically, so calling `agent.someMethod()` re-enters that agent's context:

* [  JavaScript ](#tab-panel-6539)
* [  TypeScript ](#tab-panel-6540)

JavaScript

```
import { RpcTarget } from "cloudflare:workers";
class HostCallbackBridge extends RpcTarget {  agent;
  constructor(agent) {    super();    this.agent = agent;  }
  // Invoked through RPC from a Worker Loader child isolate. There is no context  // ancestry. Calling a public agent method restores it automatically.  async invoke() {    return this.agent.handleSandboxCallback();  }}
export class MyMcpAgent extends McpAgent {  async handleSandboxCallback() {    const { agent } = getCurrentAgent();    // `agent` is available again.  }}
```

TypeScript

```
import { RpcTarget } from "cloudflare:workers";
class HostCallbackBridge extends RpcTarget {  agent: MyMcpAgent;
  constructor(agent: MyMcpAgent) {    super();    this.agent = agent;  }
  // Invoked through RPC from a Worker Loader child isolate. There is no context  // ancestry. Calling a public agent method restores it automatically.  async invoke() {    return this.agent.handleSandboxCallback();  }}
export class MyMcpAgent extends McpAgent {  async handleSandboxCallback() {    const { agent } = getCurrentAgent<MyMcpAgent>();    // `agent` is available again.  }}
```

Context restored this way has `connection`, `request`, and `email` unset. It is not tied to live client I/O.

Server-initiated MCP requests (`elicitInput`, `createMessage`, and `listRoots`) on `McpAgent` do not require this indirection because the MCP transport retains its owning agent.

## API reference

### `getCurrentAgent()`

Gets the current agent from any context where it is available.

* [  JavaScript ](#tab-panel-6523)
* [  TypeScript ](#tab-panel-6524)

JavaScript

```
import { getCurrentAgent } from "agents";
```

TypeScript

```
import { getCurrentAgent } from "agents";
function getCurrentAgent<T extends Agent>(): {  agent: T | undefined;  connection: Connection | undefined;  request: Request | undefined;  email: AgentEmail | undefined;};
```

#### Returns:

| Property   | Type                    | Description                                                   |
| ---------- | ----------------------- | ------------------------------------------------------------- |
| agent      | T \| undefined          | The current agent instance                                    |
| connection | Connection \| undefined | The WebSocket connection (if called from a WebSocket handler) |
| request    | Request \| undefined    | The HTTP request (if called from a request handler)           |
| email      | AgentEmail \| undefined | The email (if called from an email handler)                   |

#### Usage:

* [  JavaScript ](#tab-panel-6535)
* [  TypeScript ](#tab-panel-6536)

JavaScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { getCurrentAgent } from "agents";
export class MyAgent extends AIChatAgent {  async customMethod() {    const { agent, connection, request } = getCurrentAgent();    // agent is properly typed as MyAgent    // connection and request available if called from a request handler  }}
```

TypeScript

```
import { AIChatAgent } from "@cloudflare/ai-chat";import { getCurrentAgent } from "agents";
export class MyAgent extends AIChatAgent {  async customMethod() {    const { agent, connection, request } = getCurrentAgent<MyAgent>();    // agent is properly typed as MyAgent    // connection and request available if called from a request handler  }}
```

### Context availability

The context available depends on how the method was invoked:

| Invocation              | agent | connection | request | email   |
| ----------------------- | ----- | ---------- | ------- | ------- |
| onRequest()             | Yes   | No         | Yes     | No      |
| onConnect()             | Yes   | Yes        | Yes     | No      |
| onMessage()             | Yes   | Yes        | No      | No      |
| onEmail()               | Yes   | No         | No      | Yes     |
| Custom method (via RPC) | Yes   | Yes        | No      | No      |
| Scheduled task          | Yes   | No         | No      | No      |
| Queue callback          | Yes   | Depends    | Depends | Depends |

## Best practices

1. **Use `this` when possible**: Inside agent methods, prefer `this.name`, `this.state`, etc. over `getCurrentAgent()`.
2. **Use `getCurrentAgent()` in external functions**: When you need agent context in utility functions or libraries that do not have access to `this`.
3. **Check for undefined**: The returned values may be `undefined` if called outside an agent context.

  * [  JavaScript ](#tab-panel-6529)
  * [  TypeScript ](#tab-panel-6530)  
JavaScript  
```  
const { agent } = getCurrentAgent();if (agent) {  // Safe to use agent  console.log(agent.name);}  
```  
TypeScript  
```  
const { agent } = getCurrentAgent();if (agent) {  // Safe to use agent  console.log(agent.name);}  
```
4. **Type the agent**: Pass your agent class as a type parameter for proper typing.

  * [  JavaScript ](#tab-panel-6525)
  * [  TypeScript ](#tab-panel-6526)  
JavaScript  
```  
const { agent } = getCurrentAgent();// agent is typed as MyAgent | undefined  
```  
TypeScript  
```  
const { agent } = getCurrentAgent<MyAgent>();// agent is typed as MyAgent | undefined  
```

## Next steps

[ Agents API ](https://developers.cloudflare.com/agents/runtime/agents-api/) Complete API reference for the Agents SDK. 

[ Callable methods ](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/) Expose methods to clients via RPC. 

[ State management ](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) Manage and sync agent state.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/lifecycle/get-current-agent/#page","headline":"getCurrentAgent() · Cloudflare Agents docs","description":"Access the current Agent context from external utility functions using getCurrentAgent() in the Agents SDK.","url":"https://developers.cloudflare.com/agents/runtime/lifecycle/get-current-agent/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-26","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/lifecycle/","name":"Lifecycle"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/lifecycle/get-current-agent/","name":"getCurrentAgent()"}}]}
```

---

---
title: Sessions
description: Persistent conversation storage with tree-structured messages, context blocks, compaction, full-text search, and AI-controllable tools.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Sessions

The Session API provides persistent conversation storage for agents, with tree-structured messages (inspired by [Pi ↗](https://pi.dev)), context blocks, compaction, full-text search, and AI-controllable tools. By default, it uses Durable Object SQLite. External Postgres storage is also available for apps that need shared database access, analytics, or cross-Durable Object queries.

Experimental

The Session API is under `agents/experimental/memory/session`. The API surface is stable but may evolve before graduating to the main package.

## Quick start

* [  JavaScript ](#tab-panel-6557)
* [  TypeScript ](#tab-panel-6558)

JavaScript

```
import { Agent } from "agents";import { Session } from "agents/experimental/memory/session";
class MyAgent extends Agent {  session = Session.create(this)    .withContext("soul", {      provider: { get: async () => "You are a helpful assistant." },    })    .withContext("memory", {      description: "Learned facts about the user",      maxTokens: 1100,    })    .withCachedPrompt();
  async onMessage(message) {    await this.session.appendMessage(message);    const history = await this.session.getHistory();    const system = await this.session.freezeSystemPrompt();    const tools = await this.session.tools();    // Pass history, system prompt, and tools to your LLM  }}
```

TypeScript

```
import { Agent } from "agents";import { Session } from "agents/experimental/memory/session";
class MyAgent extends Agent {  session = Session.create(this)    .withContext("soul", {      provider: { get: async () => "You are a helpful assistant." },    })    .withContext("memory", {      description: "Learned facts about the user",      maxTokens: 1100,    })    .withCachedPrompt();
  async onMessage(message: unknown) {    await this.session.appendMessage(message);    const history = await this.session.getHistory();    const system = await this.session.freezeSystemPrompt();    const tools = await this.session.tools();    // Pass history, system prompt, and tools to your LLM  }}
```

## Creating a session

### Builder API (recommended)

Use `Session.create(agent)` with a chainable builder. Context providers without an explicit `provider` option are auto-wired to SQLite.

* [  JavaScript ](#tab-panel-6543)
* [  TypeScript ](#tab-panel-6544)

JavaScript

```
const session = Session.create(this)  .withContext("soul", { provider: { get: async () => "You are helpful." } })  .withContext("memory", { description: "Learned facts", maxTokens: 1100 })  .withCachedPrompt()  .onCompaction(myCompactFn)  .compactAfter(100_000);
```

TypeScript

```
const session = Session.create(this)  .withContext("soul", { provider: { get: async () => "You are helpful." } })  .withContext("memory", { description: "Learned facts", maxTokens: 1100 })  .withCachedPrompt()  .onCompaction(myCompactFn)  .compactAfter(100_000);
```

### Direct constructor

For full control over providers:

* [  JavaScript ](#tab-panel-6553)
* [  TypeScript ](#tab-panel-6554)

JavaScript

```
import {  Session,  AgentSessionProvider,  AgentContextProvider,} from "agents/experimental/memory/session";
const session = new Session(new AgentSessionProvider(this), {  context: [    {      label: "memory",      description: "Notes",      maxTokens: 500,      provider: new AgentContextProvider(this, "memory"),    },    { label: "soul", provider: { get: async () => "You are helpful." } },  ],});
```

TypeScript

```
import {  Session,  AgentSessionProvider,  AgentContextProvider,} from "agents/experimental/memory/session";
const session = new Session(new AgentSessionProvider(this), {  context: [    {      label: "memory",      description: "Notes",      maxTokens: 500,      provider: new AgentContextProvider(this, "memory"),    },    { label: "soul", provider: { get: async () => "You are helpful." } },  ],});
```

### Builder methods

All builder methods return `this` for chaining. Order does not matter — providers are resolved lazily on first use.

| Method                                  | Description                                                                                                                                              |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Session.create(agent)                   | Static factory. agent is any object with a sql tagged template method (your Agent or Durable Object).                                                    |
| .forSession(sessionId)                  | Namespace this session by ID. Required for multi-session isolation when not using SessionManager.                                                        |
| .withContext(label, options?)           | Add a context block. Refer to [Context blocks](#context-blocks).                                                                                         |
| .withCachedPrompt(provider?)            | Enable system prompt persistence. The prompt is frozen on first use and survives hibernation and eviction.                                               |
| .onCompaction(fn)                       | Register a compaction function. Refer to [Compaction](#compaction).                                                                                      |
| .compactAfter(tokenThreshold, options?) | Auto-compact when estimated token count exceeds the threshold. Requires .onCompaction(). Pass { tokenCounter } to control how the threshold is measured. |
| .onCompactionError(handler)             | Handle errors from automatic compaction. Handler failures are swallowed so message writes remain non-fatal.                                              |

## Messages

Messages use the `SessionMessage` type — a minimal shape with `id`, `role`, `parts`, and optional `createdAt`. The AI SDK's `UIMessage` is structurally compatible and can be passed directly. The session stores messages in a tree structure via `parent_id`, enabling branching conversations.

* [  JavaScript ](#tab-panel-6551)
* [  TypeScript ](#tab-panel-6552)

JavaScript

```
// Append — auto-parents to the latest leaf unless parentId is specifiedawait session.appendMessage(message);await session.appendMessage(message, parentId);
// Update an existing message (matched by message.id)await session.updateMessage(message);
// Delete specific messagesawait session.deleteMessages(["msg-1", "msg-2"]);
// Clear all messages and skill stateawait session.clearMessages();
```

TypeScript

```
// Append — auto-parents to the latest leaf unless parentId is specifiedawait session.appendMessage(message);await session.appendMessage(message, parentId);
// Update an existing message (matched by message.id)await session.updateMessage(message);
// Delete specific messagesawait session.deleteMessages(["msg-1", "msg-2"]);
// Clear all messages and skill stateawait session.clearMessages();
```

Note

Session methods are async. SQLite-backed sessions are usually fast, but external providers may perform network I/O, and `appendMessage()` may also trigger auto-compaction.

### Reading history

* [  JavaScript ](#tab-panel-6559)
* [  TypeScript ](#tab-panel-6560)

JavaScript

```
// Linear history from root to the latest leafconst messages = await session.getHistory();
// History to a specific leaf (for branching)const branch = await session.getHistory(leafId);
// Get a single messageconst msg = await session.getMessage("msg-1");
// Get the newest messageconst latest = await session.getLatestLeaf();
// Count messages in pathconst count = await session.getPathLength();
```

TypeScript

```
// Linear history from root to the latest leafconst messages = await session.getHistory();
// History to a specific leaf (for branching)const branch = await session.getHistory(leafId);
// Get a single messageconst msg = await session.getMessage("msg-1");
// Get the newest messageconst latest = await session.getLatestLeaf();
// Count messages in pathconst count = await session.getPathLength();
```

### Branching

Messages form a tree. When you `appendMessage` with a `parentId` that already has children, you create a branch. Use `getBranches()` to get all child messages branching from a given point:

* [  JavaScript ](#tab-panel-6545)
* [  TypeScript ](#tab-panel-6546)

JavaScript

```
// Get all child messages that branch from messageIdconst branches = await session.getBranches(messageId);
```

TypeScript

```
// Get all child messages that branch from messageIdconst branches = await session.getBranches(messageId);
```

This powers features like response regeneration — pass the user message ID to get both the original and regenerated responses. `getHistory(leafId)` walks the chosen path.

## Search

Full-text search over the conversation history using SQLite FTS5:

* [  JavaScript ](#tab-panel-6547)
* [  TypeScript ](#tab-panel-6548)

JavaScript

```
const results = await session.search("deployment Friday", { limit: 10 });// Returns: Array<{ id, role, content, createdAt? }>
```

TypeScript

```
const results = await session.search("deployment Friday", { limit: 10 });// Returns: Array<{ id, role, content, createdAt? }>
```

SQLite-backed sessions use FTS5 with porter stemming and unicode tokenization. Postgres-backed sessions use the provider's Postgres full-text index. `search()` throws if the session provider does not support search.

## Context blocks

Context blocks are persistent key-value sections injected into the system prompt. Each block has a **label**, optional **description**, and a **provider** that determines its behavior.

### Provider types

There are four provider types, detected by duck-typing:

| Provider                    | Interface                   | Behavior                                                                                     | AI tool                                      |
| --------------------------- | --------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------- |
| **ContextProvider**         | get()                       | Read-only block in system prompt                                                             | —                                            |
| **WritableContextProvider** | get() \+ set()              | Writable via AI                                                                              | set\_context                                 |
| **SkillProvider**           | get() \+ load() \+ set?()   | On-demand keyed documents. get() returns a metadata listing; load(key) fetches full content. | load\_context, unload\_context, set\_context |
| **SearchProvider**          | get() \+ search() \+ set?() | Full-text searchable entries. get() returns a summary; search(query) runs FTS5.              | search\_context, set\_context                |

### Built-in providers

**`AgentContextProvider`** — SQLite-backed writable context. This is the default when using the builder without an explicit provider.

* [  JavaScript ](#tab-panel-6549)
* [  TypeScript ](#tab-panel-6550)

JavaScript

```
import { AgentContextProvider } from "agents/experimental/memory/session";
new AgentContextProvider(this, "memory");
```

TypeScript

```
import { AgentContextProvider } from "agents/experimental/memory/session";
new AgentContextProvider(this, "memory");
```

**`R2SkillProvider`** — Cloudflare R2 bucket for on-demand document loading. Skills are listed in the system prompt as metadata; the model loads full content on demand via `load_context`.

* [  JavaScript ](#tab-panel-6555)
* [  TypeScript ](#tab-panel-6556)

JavaScript

```
import { R2SkillProvider } from "agents/experimental/memory/session";
Session.create(this).withContext("skills", {  provider: new R2SkillProvider(env.SKILLS_BUCKET, { prefix: "skills/" }),});
```

TypeScript

```
import { R2SkillProvider } from "agents/experimental/memory/session";
Session.create(this).withContext("skills", {  provider: new R2SkillProvider(env.SKILLS_BUCKET, { prefix: "skills/" }),});
```

**`AgentSearchProvider`** — SQLite FTS5 searchable context. Entries are indexed and searchable by the model via `search_context`.

* [  JavaScript ](#tab-panel-6561)
* [  TypeScript ](#tab-panel-6562)

JavaScript

```
import { AgentSearchProvider } from "agents/experimental/memory/session";
Session.create(this).withContext("knowledge", {  description: "Searchable knowledge base",  provider: new AgentSearchProvider(this),});
```

TypeScript

```
import { AgentSearchProvider } from "agents/experimental/memory/session";
Session.create(this).withContext("knowledge", {  description: "Searchable knowledge base",  provider: new AgentSearchProvider(this),});
```

### Adding and removing context at runtime

Blocks can be added and removed dynamically after initialization:

* [  JavaScript ](#tab-panel-6567)
* [  TypeScript ](#tab-panel-6568)

JavaScript

```
// Add a new block (auto-wires to SQLite if no provider given)await session.addContext("extension-notes", {  description: "From extension X",  maxTokens: 500,});
// Remove itsession.removeContext("extension-notes");
// Rebuild the system prompt to reflect changesawait session.refreshSystemPrompt();
```

TypeScript

```
// Add a new block (auto-wires to SQLite if no provider given)await session.addContext("extension-notes", {  description: "From extension X",  maxTokens: 500,});
// Remove itsession.removeContext("extension-notes");
// Rebuild the system prompt to reflect changesawait session.refreshSystemPrompt();
```

Note

`addContext` and `removeContext` do not automatically update the frozen system prompt. Call `refreshSystemPrompt()` afterward.

### Reading and writing context

* [  JavaScript ](#tab-panel-6569)
* [  TypeScript ](#tab-panel-6570)

JavaScript

```
// Read a single blockconst block = session.getContextBlock("memory");// { label, description?, content, tokens, maxTokens?, writable, isSkill, isSearchable }
// Read all blocksconst blocks = session.getContextBlocks();
// Replace content entirelyawait session.replaceContextBlock("memory", "User likes coffee.");
// Append contentawait session.appendContextBlock("memory", "\nUser prefers dark roast.");
```

TypeScript

```
// Read a single blockconst block = session.getContextBlock("memory");// { label, description?, content, tokens, maxTokens?, writable, isSkill, isSearchable }
// Read all blocksconst blocks = session.getContextBlocks();
// Replace content entirelyawait session.replaceContextBlock("memory", "User likes coffee.");
// Append contentawait session.appendContextBlock("memory", "\nUser prefers dark roast.");
```

### System prompt

The system prompt is built from all context blocks with headers and metadata:

```
══════════════════════════════════════════════SOUL (Identity) [readonly]══════════════════════════════════════════════You are a helpful assistant.
══════════════════════════════════════════════MEMORY (Learned facts) [45% — 495/1100 tokens]══════════════════════════════════════════════User likes coffee.User prefers dark roast.
```

* [  JavaScript ](#tab-panel-6565)
* [  TypeScript ](#tab-panel-6566)

JavaScript

```
// Freeze — first call renders and persists; subsequent calls return cached valueconst prompt = await session.freezeSystemPrompt();
// Refresh — re-render from current block state and persistconst updated = await session.refreshSystemPrompt();
```

TypeScript

```
// Freeze — first call renders and persists; subsequent calls return cached valueconst prompt = await session.freezeSystemPrompt();
// Refresh — re-render from current block state and persistconst updated = await session.refreshSystemPrompt();
```

The frozen prompt survives Durable Object hibernation and eviction when `withCachedPrompt()` is enabled.

## AI tools

Session automatically generates tools based on the provider types of your context blocks. Pass these to your LLM alongside your own tools.

* [  JavaScript ](#tab-panel-6563)
* [  TypeScript ](#tab-panel-6564)

JavaScript

```
const tools = await session.tools();const allTools = { ...tools, ...myTools };
```

TypeScript

```
const tools = await session.tools();const allTools = { ...tools, ...myTools };
```

### set\_context

Generated when any writable block exists. Writes to regular blocks, skill blocks (keyed), or search blocks (keyed). Enforces `maxTokens` limits.

### load\_context

Generated when any skill block exists. Loads full content by key from a `SkillProvider`.

### unload\_context

Generated alongside `load_context`. Frees context space by unloading a previously loaded skill. The skill remains available for re-loading.

### search\_context

Generated when any search block exists. Full-text search within a searchable context block. Returns top 10 results by FTS5 rank.

### session\_search

Available on `SessionManager` only. Searches across all sessions.

## Compaction

Compaction summarizes older messages to keep conversations within token limits. Original messages are preserved in SQLite — the summary is a non-destructive overlay applied at read time.

### Setup

* [  JavaScript ](#tab-panel-6575)
* [  TypeScript ](#tab-panel-6576)

JavaScript

```
import { createCompactFunction } from "agents/experimental/memory/utils/compaction-helpers";
const session = Session.create(this)  .withContext("memory", { maxTokens: 1100 })  .onCompaction(    createCompactFunction({      summarize: (prompt) =>        generateText({ model: myModel, prompt }).then((r) => r.text),      protectHead: 3,      tailTokenBudget: 20000,      minTailMessages: 2,      tokenCounter: async (messages) => estimateWithYourTokenizer({ messages }),    }),  )  .compactAfter(100_000);
```

TypeScript

```
import { createCompactFunction } from "agents/experimental/memory/utils/compaction-helpers";
const session = Session.create(this)  .withContext("memory", { maxTokens: 1100 })  .onCompaction(    createCompactFunction({      summarize: (prompt) =>        generateText({ model: myModel, prompt }).then((r) => r.text),      protectHead: 3,      tailTokenBudget: 20000,      minTailMessages: 2,      tokenCounter: async (messages) => estimateWithYourTokenizer({ messages }),    }),  )  .compactAfter(100_000);
```

### How compaction works

1. **Protect head** — first N messages are never compacted (default 3)
2. **Protect tail** — walk backward from the end, accumulating tokens up to a budget (default 20K tokens)
3. **Align boundaries** — shift boundaries to avoid splitting tool call/result pairs
4. **Summarize middle** — send the middle section to an LLM with a structured format (Topic, Key Points, Current State, Open Items)
5. **Store overlay** — saved in the `assistant_compactions` table, keyed by `fromMessageId` and `toMessageId`
6. **Iterative** — on subsequent compactions, the existing summary is passed to the LLM to update rather than replace

When `getHistory()` is called, compaction overlays are applied transparently — the compacted range is replaced by a synthetic summary message.

### Manual compaction

* [  JavaScript ](#tab-panel-6571)
* [  TypeScript ](#tab-panel-6572)

JavaScript

```
const result = await session.compact();
// Or manage overlays directlyawait session.addCompaction("Summary of messages 1-50", "msg-1", "msg-50");const overlays = await session.getCompactions();
```

TypeScript

```
const result = await session.compact();
// Or manage overlays directlyawait session.addCompaction("Summary of messages 1-50", "msg-1", "msg-50");const overlays = await session.getCompactions();
```

### Auto-compaction

When `.compactAfter(threshold)` is set, `appendMessage()` checks the estimated token count after each write. If it exceeds the threshold, `compact()` is called automatically. Auto-compaction failure is non-fatal — the message is already saved.

Note

Auto-compaction is checked **between turns** (on each `appendMessage()`), not within a turn. A single long, tool-heavy turn can grow past the model context window mid-flight, before the next check. `@cloudflare/think` adds opt-in mid-turn recovery on top of this — refer to [Context-window overflow recovery](https://developers.cloudflare.com/agents/harnesses/think/recovery/#context-window-overflow-recovery).

By default, the estimate includes stored message parts plus the Session-managed frozen system prompt, so context blocks and cached prompts managed by `Session` contribute to the threshold. It does not include framework-specific prompt additions or tool schema serialization that happen outside `Session`.

There are two token-counting decisions:

* `.compactAfter(threshold, { tokenCounter })` controls **when** automatic compaction is triggered after writes.
* `createCompactFunction({ tokenCounter })` controls **which** tail messages are protected from summarization. Use this when tool-heavy histories are much larger than the Workers-safe heuristic can estimate.

You usually only need to configure one counter. The `.compactAfter()` counter also flows into `createCompactFunction`'s boundary walk (via `CompactContext`) when no explicit `createCompactFunction({ tokenCounter })` is given, so a single counter drives both "should we compact?" and "what should we compact?".

Warning

The flowed counter is invoked **per message** during the boundary walk. A tokenizer-style counter budgets accurately; a usage-only counter that returns a fixed whole-prompt total (for example `usage.inputTokens` regardless of which messages are passed) degrades the tail budget to `minTailMessages` — compaction still runs and context stays bounded, but the byte budget is effectively ignored. Pass an explicit per-message `createCompactFunction({ tokenCounter })` for precise tail budgeting.

Use a custom counter when you have model-reported usage or your own tokenizer:

* [  JavaScript ](#tab-panel-6581)
* [  TypeScript ](#tab-panel-6582)

JavaScript

```
const session = Session.create(this)  .onCompaction(myCompactFn)  .compactAfter(100_000, {    tokenCounter: async ({ messages, systemPrompt, contextBlocks }) => {      return estimateWithYourTokenizer({        messages,        systemPrompt,        contextBlocks,      });    },  })  .onCompactionError((err) => {    console.warn("Auto-compaction failed", err);  });
```

TypeScript

```
const session = Session.create(this)  .onCompaction(myCompactFn)  .compactAfter(100_000, {    tokenCounter: async ({ messages, systemPrompt, contextBlocks }) => {      return estimateWithYourTokenizer({        messages,        systemPrompt,        contextBlocks,      });    },  })  .onCompactionError((err) => {    console.warn("Auto-compaction failed", err);  });
```

Note

The default token estimation is heuristic (not tiktoken). It uses `max(chars/4, words*1.3)` with 4 tokens per-message overhead, and also applies the string heuristic to the Session-managed system prompt. Tiktoken would add 80–120 MB heap overhead, which exceeds Cloudflare Workers' 128 MB limit.

## SessionManager

`SessionManager` is a registry for multiple named sessions within a single Durable Object. It provides lifecycle management, convenience methods, and cross-session search.

### Creating a SessionManager

* [  JavaScript ](#tab-panel-6577)
* [  TypeScript ](#tab-panel-6578)

JavaScript

```
import { SessionManager } from "agents/experimental/memory/session";
const manager = SessionManager.create(this)  .withContext("soul", { provider: { get: async () => "You are helpful." } })  .withContext("memory", { description: "Learned facts", maxTokens: 1100 })  .withCachedPrompt()  .onCompaction(myCompactFn)  .compactAfter(100_000)  .withSearchableHistory("history");
```

TypeScript

```
import { SessionManager } from "agents/experimental/memory/session";
const manager = SessionManager.create(this)  .withContext("soul", { provider: { get: async () => "You are helpful." } })  .withContext("memory", { description: "Learned facts", maxTokens: 1100 })  .withCachedPrompt()  .onCompaction(myCompactFn)  .compactAfter(100_000)  .withSearchableHistory("history");
```

Context blocks, prompt caching, and compaction settings are propagated to all sessions created through the manager. Provider keys are automatically namespaced by session ID.

### Builder methods

| Method                                  | Description                                                                                             |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| SessionManager.create(agent)            | Static factory.                                                                                         |
| .withContext(label, options?)           | Add context block template for all sessions.                                                            |
| .withCachedPrompt(provider?)            | Enable prompt persistence for all sessions.                                                             |
| .onCompaction(fn)                       | Register compaction function for all sessions.                                                          |
| .compactAfter(tokenThreshold, options?) | Auto-compact threshold for all sessions. Supports the same tokenCounter option as Session.              |
| .onCompactionError(handler)             | Handle automatic compaction errors for managed sessions.                                                |
| .withSearchableHistory(label)           | Add a cross-session searchable history block. The model can search past conversations from any session. |

### Session lifecycle

* [  JavaScript ](#tab-panel-6589)
* [  TypeScript ](#tab-panel-6590)

JavaScript

```
// Create a new sessionconst info = await manager.create("My Chat");
// Create with metadataconst info2 = await manager.create("My Chat", {  parentSessionId: "parent-id",  model: "claude-sonnet-4-20250514",  source: "web",});
// Get session metadata (null if not found)const session = await manager.get(sessionId);
// List all sessions (ordered by updated_at DESC)const sessions = await manager.list();
// Renameawait manager.rename(sessionId, "New Name");
// Delete (clears messages too)await manager.delete(sessionId);
```

TypeScript

```
// Create a new sessionconst info = await manager.create("My Chat");
// Create with metadataconst info2 = await manager.create("My Chat", {  parentSessionId: "parent-id",  model: "claude-sonnet-4-20250514",  source: "web",});
// Get session metadata (null if not found)const session = await manager.get(sessionId);
// List all sessions (ordered by updated_at DESC)const sessions = await manager.list();
// Renameawait manager.rename(sessionId, "New Name");
// Delete (clears messages too)await manager.delete(sessionId);
```

### Accessing sessions

* [  JavaScript ](#tab-panel-6573)
* [  TypeScript ](#tab-panel-6574)

JavaScript

```
// Get or create the Session instance for an ID// Lazy — creates on first access, caches for subsequent callsconst session = manager.getSession(sessionId);
```

TypeScript

```
// Get or create the Session instance for an ID// Lazy — creates on first access, caches for subsequent callsconst session = manager.getSession(sessionId);
```

### Message convenience methods

These delegate to the underlying Session and update the session's `updated_at` timestamp:

* [  JavaScript ](#tab-panel-6591)
* [  TypeScript ](#tab-panel-6592)

JavaScript

```
// Append a single messageawait manager.append(sessionId, message, parentId);
// Add or update (upsert)await manager.upsert(sessionId, message, parentId);
// Batch append (auto-chains parent IDs)await manager.appendAll(sessionId, messages, parentId);
// Read historyconst history = await manager.getHistory(sessionId, leafId);
// Message countconst count = await manager.getMessageCount(sessionId);
// Clear messagesawait manager.clearMessages(sessionId);
// Delete specific messagesawait manager.deleteMessages(sessionId, ["msg-1"]);
```

TypeScript

```
// Append a single messageawait manager.append(sessionId, message, parentId);
// Add or update (upsert)await manager.upsert(sessionId, message, parentId);
// Batch append (auto-chains parent IDs)await manager.appendAll(sessionId, messages, parentId);
// Read historyconst history = await manager.getHistory(sessionId, leafId);
// Message countconst count = await manager.getMessageCount(sessionId);
// Clear messagesawait manager.clearMessages(sessionId);
// Delete specific messagesawait manager.deleteMessages(sessionId, ["msg-1"]);
```

### Forking

Fork a session at a specific message — copies history up to that point into a new session:

* [  JavaScript ](#tab-panel-6579)
* [  TypeScript ](#tab-panel-6580)

JavaScript

```
const forked = await manager.fork(sessionId, atMessageId, "Forked Chat");// forked.parent_session_id === sessionId
```

TypeScript

```
const forked = await manager.fork(sessionId, atMessageId, "Forked Chat");// forked.parent_session_id === sessionId
```

### Compaction helpers

* [  JavaScript ](#tab-panel-6587)
* [  TypeScript ](#tab-panel-6588)

JavaScript

```
// Add a compaction overlayawait manager.addCompaction(sessionId, summary, fromId, toId);
// Get overlaysconst compactions = await manager.getCompactions(sessionId);
// Compact and split — marks old session as ended, creates a continuationconst continuation = await manager.compactAndSplit(  sessionId,  summary,  "Continued Chat",);
```

TypeScript

```
// Add a compaction overlayawait manager.addCompaction(sessionId, summary, fromId, toId);
// Get overlaysconst compactions = await manager.getCompactions(sessionId);
// Compact and split — marks old session as ended, creates a continuationconst continuation = await manager.compactAndSplit(  sessionId,  summary,  "Continued Chat",);
```

`compactAndSplit()` creates a new session with a summary message instead of an in-place overlay. The original session is marked with `end_reason: "compaction"`.

### Usage tracking

* [  JavaScript ](#tab-panel-6583)
* [  TypeScript ](#tab-panel-6584)

JavaScript

```
await manager.addUsage(sessionId, inputTokens, outputTokens, cost);
```

TypeScript

```
await manager.addUsage(sessionId, inputTokens, outputTokens, cost);
```

### Cross-session search

* [  JavaScript ](#tab-panel-6585)
* [  TypeScript ](#tab-panel-6586)

JavaScript

```
// Search across all sessions (FTS5)const results = await manager.search("deployment Friday", { limit: 20 });
// Get tools for the model (includes session_search)const tools = await manager.tools();
```

TypeScript

```
// Search across all sessions (FTS5)const results = await manager.search("deployment Friday", { limit: 20 });
// Get tools for the model (includes session_search)const tools = await manager.tools();
```

## Custom providers

Implement any of the four provider interfaces to plug in your own storage:

* [  JavaScript ](#tab-panel-6593)
* [  TypeScript ](#tab-panel-6594)

JavaScript

```
// Read-only contextconst myProvider = {  get: async () => "Static content here",};
// Writable context (enables set_context tool)const myWritable = {  get: async () => fetchFromMyDB(),  set: async (content) => saveToMyDB(content),};
// Skill provider (enables load_context tool)const mySkills = {  get: async () => "- api-ref: API Reference\n- guide: User Guide",  load: async (key) => fetchDocument(key),  set: async (key, content, description) =>    saveDocument(key, content, description),};
// Search provider (enables search_context tool)const mySearch = {  get: async () => "42 entries indexed",  search: async (query) => searchMyIndex(query),  set: async (key, content) => indexContent(key, content),};
```

TypeScript

```
// Read-only contextconst myProvider: ContextProvider = {  get: async () => "Static content here",};
// Writable context (enables set_context tool)const myWritable: WritableContextProvider = {  get: async () => fetchFromMyDB(),  set: async (content) => saveToMyDB(content),};
// Skill provider (enables load_context tool)const mySkills: SkillProvider = {  get: async () => "- api-ref: API Reference\n- guide: User Guide",  load: async (key) => fetchDocument(key),  set: async (key, content, description) =>    saveDocument(key, content, description),};
// Search provider (enables search_context tool)const mySearch: SearchProvider = {  get: async () => "42 entries indexed",  search: async (query) => searchMyIndex(query),  set: async (key, content) => indexContent(key, content),};
```

You can also implement `SessionProvider` to replace the SQLite storage entirely:

* [  JavaScript ](#tab-panel-6595)
* [  TypeScript ](#tab-panel-6596)

JavaScript

```
const myStorage = {  async getMessage(id) {    /* ... */  },  async getHistory(leafId) {    /* ... */  },  async getLatestLeaf() {    /* ... */  },  async getBranches(messageId) {    /* ... */  },  async getPathLength(leafId) {    /* ... */  },  async appendMessage(message, parentId) {    /* ... */  },  async updateMessage(message) {    /* ... */  },  async deleteMessages(messageIds) {    /* ... */  },  async clearMessages() {    /* ... */  },  async addCompaction(summary, fromId, toId) {    /* ... */  },  async getCompactions() {    /* ... */  },  async searchMessages(query, limit) {    /* ... */  },};
```

TypeScript

```
const myStorage: SessionProvider = {  async getMessage(id) {    /* ... */  },  async getHistory(leafId?) {    /* ... */  },  async getLatestLeaf() {    /* ... */  },  async getBranches(messageId) {    /* ... */  },  async getPathLength(leafId?) {    /* ... */  },  async appendMessage(message, parentId?) {    /* ... */  },  async updateMessage(message) {    /* ... */  },  async deleteMessages(messageIds) {    /* ... */  },  async clearMessages() {    /* ... */  },  async addCompaction(summary, fromId, toId) {    /* ... */  },  async getCompactions() {    /* ... */  },  async searchMessages(query, limit) {    /* ... */  },};
```

## Postgres providers

By default, Session storage uses Durable Object SQLite and creates tables lazily. If you need session data in an external Postgres database for cross-agent queries, analytics, or shared storage, use `PostgresSessionProvider`, `PostgresContextProvider`, and `PostgresSearchProvider`.

These providers work with Postgres-compatible databases through [Hyperdrive](https://developers.cloudflare.com/hyperdrive/) for connection pooling.

### 1\. Create a Hyperdrive config

Create a Hyperdrive config for your Postgres database:

Terminal window

```
npx wrangler hyperdrive create my-session-db \  --connection-string="postgresql://user:password@host:port/dbname"
```

Then add the Hyperdrive binding to `wrangler.jsonc`:

* [  wrangler.jsonc ](#tab-panel-6541)
* [  wrangler.toml ](#tab-panel-6542)

JSONC

```
{  "$schema": "./node_modules/wrangler/config-schema.json",  "compatibility_flags": [    "nodejs_compat"  ],  "hyperdrive": [    {      "binding": "HYPERDRIVE",      "id": "<your-hyperdrive-id>"    }  ],  "placement": {    "mode": "smart"  }}
```

TOML

```
compatibility_flags = ["nodejs_compat"]
[[hyperdrive]]binding = "HYPERDRIVE"id = "<your-hyperdrive-id>"
[placement]mode = "smart"
```

If you know your database region, configure placement close to the database to reduce query latency.

### 2\. Create the tables

The Postgres user may not have permission to create tables at runtime. Run the schema once in your database console:

```
CREATE TABLE IF NOT EXISTS assistant_messages (  id TEXT NOT NULL,  session_id TEXT NOT NULL DEFAULT '',  parent_id TEXT,  role TEXT NOT NULL,  content TEXT NOT NULL,  text_content TEXT NOT NULL DEFAULT '',  created_at TIMESTAMPTZ DEFAULT NOW(),  content_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', text_content)) STORED,  PRIMARY KEY (session_id, id));
CREATE INDEX IF NOT EXISTS idx_assistant_msg_parent  ON assistant_messages (parent_id);CREATE INDEX IF NOT EXISTS idx_assistant_msg_session  ON assistant_messages (session_id);CREATE INDEX IF NOT EXISTS idx_assistant_msg_fts  ON assistant_messages USING GIN (content_tsv);
CREATE TABLE IF NOT EXISTS assistant_compactions (  id TEXT PRIMARY KEY,  session_id TEXT NOT NULL DEFAULT '',  summary TEXT NOT NULL,  from_message_id TEXT NOT NULL,  to_message_id TEXT NOT NULL,  created_at TIMESTAMPTZ DEFAULT NOW());
CREATE TABLE IF NOT EXISTS cf_agents_context_blocks (  label TEXT PRIMARY KEY,  content TEXT NOT NULL,  updated_at TIMESTAMPTZ DEFAULT NOW());
CREATE TABLE IF NOT EXISTS cf_agents_search_entries (  label TEXT NOT NULL,  key TEXT NOT NULL,  content TEXT NOT NULL,  content_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,  created_at TIMESTAMPTZ DEFAULT NOW(),  updated_at TIMESTAMPTZ DEFAULT NOW(),  PRIMARY KEY (label, key));
CREATE INDEX IF NOT EXISTS idx_search_entries_fts  ON cf_agents_search_entries USING GIN (content_tsv);
```

### 3\. Wire it up

Install `pg`, then create a client from the Hyperdrive connection string and pass it to the Postgres providers:

 npm  yarn  pnpm  bun 

```
npm i pg
```

```
yarn add pg
```

```
pnpm add pg
```

```
bun add pg
```

* [  JavaScript ](#tab-panel-6597)
* [  TypeScript ](#tab-panel-6598)

JavaScript

```
import { Agent } from "agents";import {  PostgresContextProvider,  PostgresSearchProvider,  PostgresSessionProvider,  Session,} from "agents/experimental/memory/session";import { Client } from "pg";
export class MyAgent extends Agent {  session;  pgClient;
  async onStart() {    const client = new Client({      connectionString: this.env.HYPERDRIVE.connectionString,    });    await client.connect();    this.pgClient = client;
    const sessionId = this.ctx.id.toString();    this.session = Session.create(      new PostgresSessionProvider(client, sessionId),    )      .withContext("soul", {        provider: {          get: async () => "You are a helpful assistant.",        },      })      .withContext("memory", {        description: "Short facts",        maxTokens: 1100,        provider: new PostgresContextProvider(client, `memory_${sessionId}`),      })      .withContext("knowledge", {        description: "Searchable knowledge base",        provider: new PostgresSearchProvider(client),      })      .withCachedPrompt(        new PostgresContextProvider(client, `_prompt_${sessionId}`),      );  }}
```

TypeScript

```
import { Agent } from "agents";import {  PostgresContextProvider,  PostgresSearchProvider,  PostgresSessionProvider,  Session,} from "agents/experimental/memory/session";import { Client } from "pg";
export class MyAgent extends Agent<Env> {  private session?: Session;  private pgClient?: Client;
  async onStart(): Promise<void> {    const client = new Client({      connectionString: this.env.HYPERDRIVE.connectionString,    });    await client.connect();    this.pgClient = client;
    const sessionId = this.ctx.id.toString();    this.session = Session.create(      new PostgresSessionProvider(client, sessionId),    )      .withContext("soul", {        provider: {          get: async () => "You are a helpful assistant.",        },      })      .withContext("memory", {        description: "Short facts",        maxTokens: 1100,        provider: new PostgresContextProvider(client, `memory_${sessionId}`),      })      .withContext("knowledge", {        description: "Searchable knowledge base",        provider: new PostgresSearchProvider(client),      })      .withCachedPrompt(        new PostgresContextProvider(client, `_prompt_${sessionId}`),      );  }}
```

### Behavior differences

When `Session.create()` receives a `SessionProvider` instead of a SQLite-backed provider, it skips SQLite auto-wiring:

* **Context blocks need explicit providers.** Each `withContext()` call that should persist data needs a `provider` option.
* **`withCachedPrompt()` needs an explicit provider.** Pass a `PostgresContextProvider` to persist the frozen system prompt.
* **Session methods are async.** Use `await` for reads and writes so the same code works with local SQLite and external storage.
* **Broadcaster support is skipped.** WebSocket status broadcasts for Session events only work with SQLite-backed sessions.

### System prompt lifecycle

`freezeSystemPrompt()` returns the cached prompt from storage. On first call, it loads context blocks from providers, renders the prompt, and persists it. Subsequent calls return the stored value without re-rendering.

Use `refreshSystemPrompt()` to force reload context blocks, re-render the prompt, and update the stored value.

## Storage tables

By default, storage is in Durable Object SQLite and tables are created lazily on first use. Postgres-backed sessions use the external tables shown in the Postgres providers section.

| Table                                                 | Purpose                                                                                                   |
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| assistant\_messages                                   | Tree-structured messages with id, session\_id, parent\_id, role, content (JSON), created\_at              |
| assistant\_compactions                                | Compaction overlays with summary, from\_message\_id, to\_message\_id                                      |
| assistant\_fts                                        | FTS5 virtual table for message search (porter stemming, unicode tokenization)                             |
| assistant\_sessions                                   | Session registry (SessionManager only) with name, parent\_session\_id, model, source, token/cost counters |
| cf\_agents\_context\_blocks                           | Persistent context block storage (AgentContextProvider)                                                   |
| cf\_agents\_search\_entries / cf\_agents\_search\_fts | Searchable context entries and FTS5 index (AgentSearchProvider)                                           |

## Acknowledgments

* Session's tree-structured messages are inspired by [Pi ↗](https://pi.dev).
* Context blocks are inspired by [Letta AI memory blocks ↗](https://www.letta.com/blog/memory-blocks).
* Formatting of blocks is inspired by [Hermes Agent ↗](https://github.com/nousresearch/hermes-agent).

## Related

* [Think](https://developers.cloudflare.com/agents/harnesses/think/) — opinionated chat agent that uses Session for conversation storage via `configureSession()`
* [Chat agents](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/) — `AIChatAgent` with its own message persistence layer
* [Store and sync state](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) — `setState()` for simpler key-value persistence

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/#page","headline":"Sessions · Cloudflare Agents docs","description":"Persistent conversation storage with tree-structured messages, context blocks, compaction, full-text search, and AI-controllable tools.","url":"https://developers.cloudflare.com/agents/runtime/lifecycle/sessions/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-05","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/lifecycle/","name":"Lifecycle"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/lifecycle/sessions/","name":"Sessions"}}]}
```

---

---
title: Store and sync state
description: Persist and sync Agent state across clients in real time using setState, SQL storage, and bidirectional updates.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Store and sync state

Agents provide built-in state management with automatic persistence and real-time synchronization across all connected clients.

## Overview

State within an Agent is:

* **Persistent** \- Automatically saves to SQLite, survives restarts and hibernation
* **Synchronized** \- Changes are broadcast to all connected WebSocket clients instantly
* **Bidirectional** \- Both server and clients can update state
* **Type-safe** \- Full TypeScript support with generics
* **Immediately consistent** \- Read your own writes
* **Thread-safe** \- Safe for concurrent updates
* **Fast** \- State is colocated wherever the Agent is running

Agent state is stored in a SQL database embedded within each individual Agent instance. You can interact with it using the higher-level `this.setState` API (recommended), which allows you to sync state and trigger events on state changes, or by directly querying the database with `this.sql`.

State vs Props

**State** is persistent data that survives restarts and syncs across clients. **[Props](https://developers.cloudflare.com/agents/runtime/communication/routing/#props)** are one-time initialization arguments passed when an agent is instantiated - use props for configuration that does not need to persist.

* [  JavaScript ](#tab-panel-6613)
* [  TypeScript ](#tab-panel-6614)

JavaScript

```
import { Agent } from "agents";
export class GameAgent extends Agent {  // Default state for new agents  initialState = {    players: [],    score: 0,    status: "waiting",  };
  // React to state changes  onStateChanged(state, source) {    if (source !== "server" && state.players.length >= 2) {      // Client added a player, start the game      this.setState({ ...state, status: "playing" });    }  }
  addPlayer(name) {    this.setState({      ...this.state,      players: [...this.state.players, name],    });  }}
```

TypeScript

```
import { Agent } from "agents";
type GameState = {  players: string[];  score: number;  status: "waiting" | "playing" | "finished";};
export class GameAgent extends Agent<Env, GameState> {  // Default state for new agents  initialState: GameState = {    players: [],    score: 0,    status: "waiting",  };
  // React to state changes  onStateChanged(state: GameState, source: Connection | "server") {    if (source !== "server" && state.players.length >= 2) {      // Client added a player, start the game      this.setState({ ...state, status: "playing" });    }  }
  addPlayer(name: string) {    this.setState({      ...this.state,      players: [...this.state.players, name],    });  }}
```

## Defining initial state

Use the `initialState` property to define default values for new agent instances:

* [  JavaScript ](#tab-panel-6603)
* [  TypeScript ](#tab-panel-6604)

JavaScript

```
export class ChatAgent extends Agent {  initialState = {    messages: [],    settings: { theme: "dark", notifications: true },    lastActive: null,  };}
```

TypeScript

```
type State = {  messages: Message[];  settings: UserSettings;  lastActive: string | null;};
export class ChatAgent extends Agent<Env, State> {  initialState: State = {    messages: [],    settings: { theme: "dark", notifications: true },    lastActive: null,  };}
```

### Type safety

The second generic parameter to `Agent` defines your state type:

* [  JavaScript ](#tab-panel-6599)
* [  TypeScript ](#tab-panel-6600)

JavaScript

```
// State is fully typedexport class MyAgent extends Agent {  initialState = { count: 0 };
  increment() {    // TypeScript knows this.state is MyState    this.setState({ count: this.state.count + 1 });  }}
```

TypeScript

```
// State is fully typedexport class MyAgent extends Agent<Env, MyState> {  initialState: MyState = { count: 0 };
  increment() {    // TypeScript knows this.state is MyState    this.setState({ count: this.state.count + 1 });  }}
```

### When initial state applies

Initial state is applied lazily on first access, not on every wake:

1. **New agent** \- `initialState` is used and persisted
2. **Existing agent** \- Persisted state is loaded from SQLite
3. **No `initialState` defined** \- `this.state` is `undefined`

* [  JavaScript ](#tab-panel-6601)
* [  TypeScript ](#tab-panel-6602)

JavaScript

```
class MyAgent extends Agent {  initialState = { count: 0 };  async onStart() {    // Safe to access - returns initialState if new, or persisted state    console.log("Current count:", this.state.count);  }}
```

TypeScript

```
class MyAgent extends Agent<Env, { count: number }> {  initialState = { count: 0 };  async onStart() {    // Safe to access - returns initialState if new, or persisted state    console.log("Current count:", this.state.count);  }}
```

## Reading state

Access the current state via the `this.state` getter:

* [  JavaScript ](#tab-panel-6609)
* [  TypeScript ](#tab-panel-6610)

JavaScript

```
class MyAgent extends Agent {  async onRequest(request) {    // Read current state    const { players, status } = this.state;
    if (status === "waiting" && players.length < 2) {      return new Response("Waiting for players...");    }
    return Response.json(this.state);  }}
```

TypeScript

```
class MyAgent extends Agent<  Env,  { players: string[]; status: "waiting" | "playing" | "finished" }> {  async onRequest(request: Request) {    // Read current state    const { players, status } = this.state;
    if (status === "waiting" && players.length < 2) {      return new Response("Waiting for players...");    }
    return Response.json(this.state);  }}
```

### Undefined state

If you do not define `initialState`, `this.state` returns `undefined`:

* [  JavaScript ](#tab-panel-6605)
* [  TypeScript ](#tab-panel-6606)

JavaScript

```
export class MinimalAgent extends Agent {  // No initialState defined
  async onConnect(connection) {    if (!this.state) {      // First time - initialize state      this.setState({ initialized: true });    }  }}
```

TypeScript

```
export class MinimalAgent extends Agent {  // No initialState defined
  async onConnect(connection: Connection) {    if (!this.state) {      // First time - initialize state      this.setState({ initialized: true });    }  }}
```

## Updating state

Use `setState()` to update state. This:

1. Saves to SQLite (persistent)
2. Broadcasts to all connected clients (excluding connections where [shouldSendProtocolMessages](https://developers.cloudflare.com/agents/runtime/communication/protocol-messages/) returned `false`)
3. Triggers `onStateChanged()` (after broadcast; best-effort)

* [  JavaScript ](#tab-panel-6611)
* [  TypeScript ](#tab-panel-6612)

JavaScript

```
// Replace entire statethis.setState({  players: ["Alice", "Bob"],  score: 0,  status: "playing",});
// Update specific fields (spread existing state)this.setState({  ...this.state,  score: this.state.score + 10,});
```

TypeScript

```
// Replace entire statethis.setState({  players: ["Alice", "Bob"],  score: 0,  status: "playing",});
// Update specific fields (spread existing state)this.setState({  ...this.state,  score: this.state.score + 10,});
```

### State must be serializable

State is stored as JSON, so it must be serializable:

* [  JavaScript ](#tab-panel-6615)
* [  TypeScript ](#tab-panel-6616)

JavaScript

```
// Good - plain objects, arrays, primitivesthis.setState({  items: ["a", "b", "c"],  count: 42,  active: true,  metadata: { key: "value" },});
// Bad - functions, classes, circular references// Functions do not serialize// Dates become strings, lose methods// Circular references fail
// For dates, use ISO stringsthis.setState({  createdAt: new Date().toISOString(),});
```

TypeScript

```
// Good - plain objects, arrays, primitivesthis.setState({  items: ["a", "b", "c"],  count: 42,  active: true,  metadata: { key: "value" },});
// Bad - functions, classes, circular references// Functions do not serialize// Dates become strings, lose methods// Circular references fail
// For dates, use ISO stringsthis.setState({  createdAt: new Date().toISOString(),});
```

## Responding to state changes

Override `onStateChanged()` to react when state changes (notifications/side-effects):

* [  JavaScript ](#tab-panel-6607)
* [  TypeScript ](#tab-panel-6608)

JavaScript

```
class MyAgent extends Agent {  onStateChanged(state, source) {    console.log("State updated:", state);    console.log("Updated by:", source === "server" ? "server" : source.id);  }}
```

TypeScript

```
class MyAgent extends Agent<Env, GameState> {  onStateChanged(state: GameState, source: Connection | "server") {    console.log("State updated:", state);    console.log("Updated by:", source === "server" ? "server" : source.id);  }}
```

### The source parameter

The `source` shows who triggered the update:

| Value      | Meaning                             |
| ---------- | ----------------------------------- |
| "server"   | Agent called setState()             |
| Connection | A client pushed state via WebSocket |

This is useful for:

* Avoiding infinite loops (do not react to your own updates)
* Validating client input
* Triggering side effects only on client actions

* [  JavaScript ](#tab-panel-6619)
* [  TypeScript ](#tab-panel-6620)

JavaScript

```
class MyAgent extends Agent {  onStateChanged(state, source) {    // Ignore server-initiated updates    if (source === "server") return;
    // A client updated state - validate and process    const connection = source;    console.log(`Client ${connection.id} updated state`);
    // Maybe trigger something based on the change    if (state.status === "submitted") {      this.processSubmission(state);    }  }}
```

TypeScript

```
class MyAgent extends Agent<  Env,  { status: "waiting" | "playing" | "finished" }> {  onStateChanged(state: GameState, source: Connection | "server") {    // Ignore server-initiated updates    if (source === "server") return;
    // A client updated state - validate and process    const connection = source;    console.log(`Client ${connection.id} updated state`);
    // Maybe trigger something based on the change    if (state.status === "submitted") {      this.processSubmission(state);    }  }}
```

### Common pattern: Client-driven actions

* [  JavaScript ](#tab-panel-6621)
* [  TypeScript ](#tab-panel-6622)

JavaScript

```
class MyAgent extends Agent {  onStateChanged(state, source) {    if (source === "server") return;
    // Client added a message    const lastMessage = state.messages[state.messages.length - 1];    if (lastMessage && !lastMessage.processed) {      // Process and update      this.setState({        ...state,        messages: state.messages.map((m) =>          m.id === lastMessage.id ? { ...m, processed: true } : m,        ),      });    }  }}
```

TypeScript

```
class MyAgent extends Agent<Env, { messages: Message[] }> {  onStateChanged(state: State, source: Connection | "server") {    if (source === "server") return;
    // Client added a message    const lastMessage = state.messages[state.messages.length - 1];    if (lastMessage && !lastMessage.processed) {      // Process and update      this.setState({        ...state,        messages: state.messages.map((m) =>          m.id === lastMessage.id ? { ...m, processed: true } : m,        ),      });    }  }}
```

## Validating state updates

If you want to validate or reject state updates, override `validateStateChange()`:

* Runs before persistence and broadcast
* Must be synchronous
* Throwing aborts the update

* [  JavaScript ](#tab-panel-6617)
* [  TypeScript ](#tab-panel-6618)

JavaScript

```
class MyAgent extends Agent {  validateStateChange(nextState, source) {    // Example: reject negative scores    if (nextState.score < 0) {      throw new Error("score cannot be negative");    }
    // Example: only allow certain status transitions    if (this.state.status === "finished" && nextState.status !== "finished") {      throw new Error("Cannot restart a finished game");    }  }}
```

TypeScript

```
class MyAgent extends Agent<Env, GameState> {  validateStateChange(nextState: GameState, source: Connection | "server") {    // Example: reject negative scores    if (nextState.score < 0) {      throw new Error("score cannot be negative");    }
    // Example: only allow certain status transitions    if (this.state.status === "finished" && nextState.status !== "finished") {      throw new Error("Cannot restart a finished game");    }  }}
```

Note

`onStateChanged()` is not intended for validation; it is a notification hook and should not block broadcasts. Use `validateStateChange()` for validation.

## Client-side state sync

State synchronizes automatically with connected clients.

### React (useAgent)

* [  JavaScript ](#tab-panel-6629)
* [  TypeScript ](#tab-panel-6630)

JavaScript

```
import { useAgent } from "agents/react";
function GameUI() {  const agent = useAgent({    agent: "game-agent",    name: "room-123",    onStateUpdate: (state, source) => {      console.log("State updated:", state);    },  });
  // Push state to agent  const addPlayer = (name) => {    agent.setState({      ...agent.state,      players: [...agent.state.players, name],    });  };
  return <div>Players: {agent.state?.players.join(", ")}</div>;}
```

TypeScript

```
import { useAgent } from "agents/react";
function GameUI() {  const agent = useAgent({    agent: "game-agent",    name: "room-123",    onStateUpdate: (state, source) => {      console.log("State updated:", state);    }  });
  // Push state to agent  const addPlayer = (name: string) => {    agent.setState({      ...agent.state,      players: [...agent.state.players, name]    });  };
  return <div>Players: {agent.state?.players.join(", ")}</div>;}
```

### Vanilla JS (AgentClient)

* [  JavaScript ](#tab-panel-6623)
* [  TypeScript ](#tab-panel-6624)

JavaScript

```
import { AgentClient } from "agents/client";
const client = new AgentClient({  agent: "game-agent",  name: "room-123",  onStateUpdate: (state) => {    document.getElementById("score").textContent = state.score;  },});
// Push state updateclient.setState({ ...client.state, score: 100 });
```

TypeScript

```
import { AgentClient } from "agents/client";
const client = new AgentClient({  agent: "game-agent",  name: "room-123",  onStateUpdate: (state) => {    document.getElementById("score").textContent = state.score;  },});
// Push state updateclient.setState({ ...client.state, score: 100 });
```

### State flow

flowchart TD
    subgraph Agent
        S["this.state<br/>(persisted in SQLite)"]
    end
    subgraph Clients
        C1["Client 1"]
        C2["Client 2"]
        C3["Client 3"]
    end
    C1 & C2 & C3 -->|setState| S
    S -->|broadcast via WebSocket| C1 & C2 & C3

## State from Workflows

When using [Workflows](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/), you can update agent state from workflow steps:

* [  JavaScript ](#tab-panel-6627)
* [  TypeScript ](#tab-panel-6628)

JavaScript

```
// In your workflowclass MyWorkflow extends Workflow {  async run(event, step) {    // Replace entire state    await step.updateAgentState({ status: "processing", progress: 0 });
    // Merge partial updates (preserves other fields)    await step.mergeAgentState({ progress: 50 });
    // Reset to initialState    await step.resetAgentState();
    return result;  }}
```

TypeScript

```
// In your workflowclass MyWorkflow extends Workflow<Env> {  async run(event: AgentWorkflowEvent, step: AgentWorkflowStep) {    // Replace entire state    await step.updateAgentState({ status: "processing", progress: 0 });
    // Merge partial updates (preserves other fields)    await step.mergeAgentState({ progress: 50 });
    // Reset to initialState    await step.resetAgentState();
    return result;  }}
```

These are durable operations - they persist even if the workflow retries.

## SQL API

Every individual Agent instance has its own SQL (SQLite) database that runs within the same context as the Agent itself. This means that inserting or querying data within your Agent is effectively zero-latency: the Agent does not have to round-trip across a continent or the world to access its own data.

You can access the SQL API within any method on an Agent via `this.sql`. The SQL API accepts template literals:

* [  JavaScript ](#tab-panel-6625)
* [  TypeScript ](#tab-panel-6626)

JavaScript

```
export class MyAgent extends Agent {  async onRequest(request) {    let userId = new URL(request.url).searchParams.get("userId");
    // 'users' is just an example here: you can create arbitrary tables and define your own schemas    // within each Agent's database using SQL (SQLite syntax).    let [user] = this.sql`SELECT * FROM users WHERE id = ${userId}`;    return Response.json(user);  }}
```

TypeScript

```
export class MyAgent extends Agent {  async onRequest(request: Request) {    let userId = new URL(request.url).searchParams.get("userId");
    // 'users' is just an example here: you can create arbitrary tables and define your own schemas    // within each Agent's database using SQL (SQLite syntax).    let [user] = this.sql`SELECT * FROM users WHERE id = ${userId}`;    return Response.json(user);  }}
```

You can also supply a TypeScript type argument to the query, which will be used to infer the type of the result:

* [  JavaScript ](#tab-panel-6631)
* [  TypeScript ](#tab-panel-6632)

JavaScript

```
export class MyAgent extends Agent {  async onRequest(request) {    let userId = new URL(request.url).searchParams.get("userId");    // Supply the type parameter to the query when calling this.sql    // This assumes the results returns one or more User rows with "id", "name", and "email" columns    const [user] = this.sql`SELECT * FROM users WHERE id = ${userId}`;    return Response.json(user);  }}
```

TypeScript

```
type User = {  id: string;  name: string;  email: string;};
export class MyAgent extends Agent {  async onRequest(request: Request) {    let userId = new URL(request.url).searchParams.get("userId");    // Supply the type parameter to the query when calling this.sql    // This assumes the results returns one or more User rows with "id", "name", and "email" columns    const [user] = this.sql<User>`SELECT * FROM users WHERE id = ${userId}`;    return Response.json(user);  }}
```

You do not need to specify an array type (`User[]` or `Array<User>`), as `this.sql` will always return an array of the specified type.

Note

Providing a type parameter does not validate that the result matches your type definition. If you need to validate incoming events, we recommend a library such as [zod ↗](https://zod.dev/) or your own validator logic.

The SQL API exposed to an Agent is similar to the one [within Durable Objects](https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/#sql-api). You can use the same SQL queries with the Agent's database. Create tables and query data, just as you would with Durable Objects or [D1](https://developers.cloudflare.com/d1/).

## Best practices

### Keep state small

State is broadcast to all clients on every change. For large data:

TypeScript

```
// Bad - storing large arrays in stateinitialState = {  allMessages: [] // Could grow to thousands of items};
// Good - store in SQL, keep state lightinitialState = {  messageCount: 0,  lastMessageId: null};
// Query SQL for full dataasync getMessages(limit = 50) {  return this.sql`SELECT * FROM messages ORDER BY created_at DESC LIMIT ${limit}`;}
```

### Optimistic updates

For responsive UIs, update client state immediately:

* [  JavaScript ](#tab-panel-6635)
* [  TypeScript ](#tab-panel-6636)

JavaScript

```
// Client-sidefunction sendMessage(text) {  const optimisticMessage = {    id: crypto.randomUUID(),    text,    pending: true,  };
  // Update immediately  agent.setState({    ...agent.state,    messages: [...agent.state.messages, optimisticMessage],  });
  // Server will confirm/update}
// Server-sideclass MyAgent extends Agent {  onStateChanged(state, source) {    if (source === "server") return;
    const pendingMessages = state.messages.filter((m) => m.pending);    for (const msg of pendingMessages) {      // Validate and confirm      this.setState({        ...state,        messages: state.messages.map((m) =>          m.id === msg.id ? { ...m, pending: false, timestamp: Date.now() } : m,        ),      });    }  }}
```

TypeScript

```
// Client-sidefunction sendMessage(text: string) {  const optimisticMessage = {    id: crypto.randomUUID(),    text,    pending: true,  };
  // Update immediately  agent.setState({    ...agent.state,    messages: [...agent.state.messages, optimisticMessage],  });
  // Server will confirm/update}
// Server-sideclass MyAgent extends Agent<Env, { messages: Message[] }> {  onStateChanged(state: GameState, source: Connection | "server") {    if (source === "server") return;
    const pendingMessages = state.messages.filter((m) => m.pending);    for (const msg of pendingMessages) {      // Validate and confirm      this.setState({        ...state,        messages: state.messages.map((m) =>          m.id === msg.id ? { ...m, pending: false, timestamp: Date.now() } : m,        ),      });    }  }}
```

### State vs SQL

| Use State For                      | Use SQL For       |
| ---------------------------------- | ----------------- |
| UI state (loading, selected items) | Historical data   |
| Real-time counters                 | Large collections |
| Active session data                | Relationships     |
| Configuration                      | Queryable data    |

* [  JavaScript ](#tab-panel-6633)
* [  TypeScript ](#tab-panel-6634)

JavaScript

```
export class ChatAgent extends Agent {  // State: current UI state  initialState = {    typing: [],    unreadCount: 0,    activeUsers: [],  };
  // SQL: message history  async getMessages(limit = 100) {    return this.sql`      SELECT * FROM messages      ORDER BY created_at DESC      LIMIT ${limit}    `;  }
  async saveMessage(message) {    this.sql`      INSERT INTO messages (id, text, user_id, created_at)      VALUES (${message.id}, ${message.text}, ${message.userId}, ${Date.now()})    `;    // Update state for real-time UI    this.setState({      ...this.state,      unreadCount: this.state.unreadCount + 1,    });  }}
```

TypeScript

```
export class ChatAgent extends Agent {  // State: current UI state  initialState = {    typing: [],    unreadCount: 0,    activeUsers: [],  };
  // SQL: message history  async getMessages(limit = 100) {    return this.sql`      SELECT * FROM messages      ORDER BY created_at DESC      LIMIT ${limit}    `;  }
  async saveMessage(message: Message) {    this.sql`      INSERT INTO messages (id, text, user_id, created_at)      VALUES (${message.id}, ${message.text}, ${message.userId}, ${Date.now()})    `;    // Update state for real-time UI    this.setState({      ...this.state,      unreadCount: this.state.unreadCount + 1,    });  }}
```

### Avoid infinite loops

Be careful not to trigger state updates in response to your own updates:

TypeScript

```
// Bad - infinite looponStateChanged(state: State) {  this.setState({ ...state, lastUpdated: Date.now() });}
// Good - check sourceonStateChanged(state: State, source: Connection | "server") {  if (source === "server") return; // Do not react to own updates  this.setState({ ...state, lastUpdated: Date.now() });}
```

## Use Agent state as model context

You can combine the state and SQL APIs in your Agent with its ability to [call AI models](https://developers.cloudflare.com/agents/runtime/operations/using-ai-models/) to include historical context within your prompts to a model. Modern Large Language Models (LLMs) often have very large context windows (up to millions of tokens), which allows you to pull relevant context into your prompt directly.

For example, you can use an Agent's built-in SQL database to pull history, query a model with it, and append to that history ahead of the next call to the model:

* [  JavaScript ](#tab-panel-6637)
* [  TypeScript ](#tab-panel-6638)

JavaScript

```
export class ReasoningAgent extends Agent {  async callReasoningModel(prompt) {    let result = this      .sql`SELECT * FROM history WHERE user = ${prompt.userId} ORDER BY timestamp DESC LIMIT 1000`;    let context = [];    for (const row of result) {      context.push(row.entry);    }
    const systemPrompt = prompt.system || "You are a helpful assistant.";    const userPrompt = `${prompt.user}\n\nUser history:\n${context.join("\n")}`;
    try {      const response = await this.env.AI.run("@cf/zai-org/glm-4.7-flash", {        messages: [          { role: "system", content: systemPrompt },          { role: "user", content: userPrompt },        ],      });
      // Store the response in history      this        .sql`INSERT INTO history (timestamp, user, entry) VALUES (${new Date()}, ${prompt.userId}, ${response.response})`;
      return response.response;    } catch (error) {      console.error("Error calling reasoning model:", error);      throw error;    }  }}
```

TypeScript

```
interface Env {  AI: Ai;}
export class ReasoningAgent extends Agent<Env> {  async callReasoningModel(prompt: Prompt) {    let result = this      .sql<History>`SELECT * FROM history WHERE user = ${prompt.userId} ORDER BY timestamp DESC LIMIT 1000`;    let context = [];    for (const row of result) {      context.push(row.entry);    }
    const systemPrompt = prompt.system || "You are a helpful assistant.";    const userPrompt = `${prompt.user}\n\nUser history:\n${context.join("\n")}`;
    try {      const response = await this.env.AI.run("@cf/zai-org/glm-4.7-flash", {        messages: [          { role: "system", content: systemPrompt },          { role: "user", content: userPrompt },        ],      });
      // Store the response in history      this        .sql`INSERT INTO history (timestamp, user, entry) VALUES (${new Date()}, ${prompt.userId}, ${response.response})`;
      return response.response;    } catch (error) {      console.error("Error calling reasoning model:", error);      throw error;    }  }}
```

This works because each instance of an Agent has its own database, and the state stored in that database is private to that Agent: whether it is acting on behalf of a single user, a room or channel, or a deep research tool. By default, you do not have to manage contention or reach out over the network to a centralized database to retrieve and store state.

## API reference

### Properties

| Property     | Type  | Description                  |
| ------------ | ----- | ---------------------------- |
| state        | State | Current state (getter)       |
| initialState | State | Default state for new agents |

### Methods

| Method              | Signature                                                  | Description                                   |
| ------------------- | ---------------------------------------------------------- | --------------------------------------------- |
| setState            | (state: State) => void                                     | Update state, persist, and broadcast          |
| onStateChanged      | (state: State, source: Connection \| "server") => void     | Called when state changes                     |
| validateStateChange | (nextState: State, source: Connection \| "server") => void | Validate before persistence (throw to reject) |

### Workflow step methods

| Method                        | Description                         |
| ----------------------------- | ----------------------------------- |
| step.updateAgentState(state)  | Replace agent state from workflow   |
| step.mergeAgentState(partial) | Merge partial state from workflow   |
| step.resetAgentState()        | Reset to initialState from workflow |

## Next steps

[ Agents API ](https://developers.cloudflare.com/agents/runtime/agents-api/) Complete API reference for the Agents SDK. 

[ Build a chat agent ](https://developers.cloudflare.com/agents/examples/chat-agent/) Build and deploy an AI chat agent. 

[ WebSockets ](https://developers.cloudflare.com/agents/runtime/communication/websockets/) Build interactive agents with real-time data streaming. 

[ Run Workflows ](https://developers.cloudflare.com/agents/runtime/execution/run-workflows/) Orchestrate asynchronous workflows from your agent.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/lifecycle/state/#page","headline":"Store and sync state · Cloudflare Agents docs","description":"Persist and sync Agent state across clients in real time using setState, SQL storage, and bidirectional updates.","url":"https://developers.cloudflare.com/agents/runtime/lifecycle/state/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/lifecycle/","name":"Lifecycle"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/lifecycle/state/","name":"Store and sync state"}}]}
```

---

---
title: Configuration
description: Configure Wrangler bindings, environment variables, and type generation for a project using the Agents SDK.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Configuration

This guide covers everything you need to configure agents for local development and production deployment, including Wrangler configuration file setup, type generation, environment variables, and the Cloudflare dashboard.

## Project structure

The typical file structure for an Agent project created from `npm create cloudflare@latest agents-starter -- --template cloudflare/agents-starter` follows:

* Directorysrc/  
  * index.ts your Agent definition
* Directorypublic/  
  * index.html
* Directorytest/  
  * index.spec.ts your tests
* package.json
* tsconfig.json
* vitest.config.mts
* worker-configuration.d.ts
* wrangler.jsonc your Workers and Agent configuration

## Wrangler configuration file

The `wrangler.jsonc` file configures your Cloudflare Worker and its bindings. Here is a complete example for an agents project:

* [  wrangler.jsonc ](#tab-panel-6657)
* [  wrangler.toml ](#tab-panel-6658)

JSONC

```
{  "$schema": "node_modules/wrangler/config-schema.json",  "name": "my-agent-app",  "main": "src/server.ts",  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": ["nodejs_compat"],
  // Static assets (optional)  "assets": {    "directory": "public",    "binding": "ASSETS",  },
  // Durable Object bindings for agents  "durable_objects": {    "bindings": [      {        "name": "MyAgent",        "class_name": "MyAgent",      },      {        "name": "ChatAgent",        "class_name": "ChatAgent",      },    ],  },
  // Required: Enable SQLite storage for agents  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": ["MyAgent", "ChatAgent"],    },  ],
  // AI binding (optional, for Workers AI)  "ai": {    "binding": "AI",  },
  // Observability (recommended)  "observability": {    "enabled": true,  },}
```

TOML

```
"$schema" = "node_modules/wrangler/config-schema.json"name = "my-agent-app"main = "src/server.ts"# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = [ "nodejs_compat" ]
[assets]directory = "public"binding = "ASSETS"
[[durable_objects.bindings]]name = "MyAgent"class_name = "MyAgent"
[[durable_objects.bindings]]name = "ChatAgent"class_name = "ChatAgent"
[[migrations]]tag = "v1"new_sqlite_classes = [ "MyAgent", "ChatAgent" ]
[ai]binding = "AI"
[observability]enabled = true
```

### Key fields

#### `compatibility_flags`

The `nodejs_compat` flag is required for agents:

* [  wrangler.jsonc ](#tab-panel-6639)
* [  wrangler.toml ](#tab-panel-6640)

JSONC

```
{  "compatibility_flags": ["nodejs_compat"],}
```

TOML

```
compatibility_flags = [ "nodejs_compat" ]
```

This enables Node.js compatibility mode, which agents depend on for crypto, streams, and other Node.js APIs.

#### `durable_objects.bindings`

Each agent class needs a binding:

* [  wrangler.jsonc ](#tab-panel-6641)
* [  wrangler.toml ](#tab-panel-6642)

JSONC

```
{  "durable_objects": {    "bindings": [      {        "name": "Counter",        "class_name": "Counter",      },    ],  },}
```

TOML

```
[[durable_objects.bindings]]name = "Counter"class_name = "Counter"
```

| Field       | Description                                             |
| ----------- | ------------------------------------------------------- |
| name        | The property name on env. Use this in code: env.Counter |
| class\_name | Must match the exported class name exactly              |

When `name` and `class_name` differ

When `name` and `class_name` differ, follow the pattern shown below:

* [  wrangler.jsonc ](#tab-panel-6643)
* [  wrangler.toml ](#tab-panel-6644)

JSONC

```
{  "durable_objects": {    "bindings": [      {        "name": "COUNTER_DO",        "class_name": "CounterAgent",      },    ],  },}
```

TOML

```
[[durable_objects.bindings]]name = "COUNTER_DO"class_name = "CounterAgent"
```

This is useful when you want environment variable-style naming (`COUNTER_DO`) but more descriptive class names (`CounterAgent`).

#### `migrations`

Migrations tell Cloudflare how to set up storage for your Durable Objects:

* [  wrangler.jsonc ](#tab-panel-6645)
* [  wrangler.toml ](#tab-panel-6646)

JSONC

```
{  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": ["MyAgent"],    },  ],}
```

TOML

```
[[migrations]]tag = "v1"new_sqlite_classes = [ "MyAgent" ]
```

| Field                | Description                                                  |
| -------------------- | ------------------------------------------------------------ |
| tag                  | Version identifier (for example, "v1", "v2"). Must be unique |
| new\_sqlite\_classes | Agent classes that use SQLite storage (state persistence)    |
| deleted\_classes     | Classes being removed                                        |
| renamed\_classes     | Classes being renamed                                        |

#### `assets`

For serving static files (HTML, CSS, JS):

* [  wrangler.jsonc ](#tab-panel-6647)
* [  wrangler.toml ](#tab-panel-6648)

JSONC

```
{  "assets": {    "directory": "public",    "binding": "ASSETS",  },}
```

TOML

```
[assets]directory = "public"binding = "ASSETS"
```

With a binding, you can serve assets programmatically:

* [  JavaScript ](#tab-panel-6677)
* [  TypeScript ](#tab-panel-6678)

JavaScript

```
export default {  async fetch(request, env) {    // Static assets are served by the worker automatically by default
    // Route the request to the appropriate agent    const agentResponse = await routeAgentRequest(request, env);    if (agentResponse) return agentResponse;
    // Add your own routing logic here    return new Response("Not found", { status: 404 });  },};
```

TypeScript

```
export default {  async fetch(request: Request, env: Env) {    // Static assets are served by the worker automatically by default
    // Route the request to the appropriate agent    const agentResponse = await routeAgentRequest(request, env);    if (agentResponse) return agentResponse;
    // Add your own routing logic here    return new Response("Not found", { status: 404 });  },} satisfies ExportedHandler<Env>;
```

#### `ai`

For Workers AI integration:

* [  wrangler.jsonc ](#tab-panel-6649)
* [  wrangler.toml ](#tab-panel-6650)

JSONC

```
{  "ai": {    "binding": "AI",  },}
```

TOML

```
[ai]binding = "AI"
```

Access in your agent:

* [  JavaScript ](#tab-panel-6673)
* [  TypeScript ](#tab-panel-6674)

JavaScript

```
const response = await this.env.AI.run("@cf/meta/llama-3-8b-instruct", {  prompt: "Hello!",});
```

TypeScript

```
const response = await this.env.AI.run("@cf/meta/llama-3-8b-instruct", {  prompt: "Hello!",});
```

## TypeScript configuration

The Agents SDK ships a shared `tsconfig.json` that sets all the compiler options needed for agents projects — including the `ES2021` target required for `@callable()` decorators, strict mode, bundler module resolution, and Workers types.

Extend it in your `tsconfig.json`:

```
{  "extends": "agents/tsconfig"}
```

This is equivalent to:

```
{  "compilerOptions": {    "target": "ES2021",    "lib": ["ES2022", "DOM", "DOM.Iterable"],    "jsx": "react-jsx",    "module": "ES2022",    "moduleResolution": "bundler",    "types": ["node", "@cloudflare/workers-types", "vite/client"],    "allowImportingTsExtensions": true,    "noEmit": true,    "isolatedModules": true,    "verbatimModuleSyntax": true,    "esModuleInterop": true,    "forceConsistentCasingInFileNames": true,    "strict": true,    "skipLibCheck": true  }}
```

You can override individual options as needed:

```
{  "extends": "agents/tsconfig",  "compilerOptions": {    "jsx": "preserve"  }}
```

Warning

Do not set `"experimentalDecorators": true`. The Agents SDK uses [TC39 standard decorators ↗](https://github.com/tc39/proposal-decorators), not TypeScript legacy decorators. Enabling `experimentalDecorators` applies an incompatible transform that silently breaks `@callable()` at runtime.

## Vite configuration

The Agents SDK provides a Vite plugin that handles TC39 decorator transforms. Vite 8 uses Oxc for transpilation, which does not yet support TC39 decorators — without this plugin, `@callable()` and other decorators will fail at runtime.

Add the plugin to your `vite.config.ts`:

* [  JavaScript ](#tab-panel-6679)
* [  TypeScript ](#tab-panel-6680)

JavaScript

```
import { cloudflare } from "@cloudflare/vite-plugin";import react from "@vitejs/plugin-react";import agents from "agents/vite";import { defineConfig } from "vite";
export default defineConfig({  plugins: [agents(), react(), cloudflare()],});
```

TypeScript

```
import { cloudflare } from "@cloudflare/vite-plugin";import react from "@vitejs/plugin-react";import agents from "agents/vite";import { defineConfig } from "vite";
export default defineConfig({  plugins: [agents(), react(), cloudflare()],});
```

The `agents()` plugin is safe to include even if your project does not use decorators. It only runs the transform on files that contain `@` syntax.

The starter template and all examples include this plugin by default. If you encounter `SyntaxError: Invalid or unexpected token` with decorators, refer to [Callable methods — Troubleshooting](https://developers.cloudflare.com/agents/runtime/lifecycle/callable-methods/#troubleshooting).

## Generating types

Wrangler can generate TypeScript types for your bindings.

### Automatic generation

Run the types command:

Terminal window

```
npx wrangler types
```

This creates or updates `worker-configuration.d.ts` with your `Env` type.

### Custom output path

Specify a custom path:

Terminal window

```
npx wrangler types env.d.ts
```

### Without runtime types

For cleaner output (recommended for agents):

Terminal window

```
npx wrangler types env.d.ts --include-runtime false
```

This generates just your bindings without Cloudflare runtime types.

### Example generated output

TypeScript

```
// env.d.ts (generated)declare namespace Cloudflare {  interface Env {    OPENAI_API_KEY: string;    Counter: DurableObjectNamespace;    ChatAgent: DurableObjectNamespace;  }}interface Env extends Cloudflare.Env {}
```

### Manual type definition

You can also define types manually:

* [  JavaScript ](#tab-panel-6687)
* [  TypeScript ](#tab-panel-6688)

JavaScript

```
// env.d.ts
```

TypeScript

```
// env.d.tsimport type { Counter } from "./src/agents/counter";import type { ChatAgent } from "./src/agents/chat";
interface Env {  // Secrets  OPENAI_API_KEY: string;  WEBHOOK_SECRET: string;
  // Agent bindings  Counter: DurableObjectNamespace<Counter>;  ChatAgent: DurableObjectNamespace<ChatAgent>;
  // Other bindings  AI: Ai;  ASSETS: Fetcher;  MY_KV: KVNamespace;}
```

### Adding to package.json

Add a script for easy regeneration:

```
{  "scripts": {    "types": "wrangler types env.d.ts --include-runtime false"  }}
```

## Environment variables and secrets

### Local development (`.env`)

Create a `.env` file for local secrets (add to `.gitignore`):

Terminal window

```
# .envOPENAI_API_KEY=sk-...GITHUB_WEBHOOK_SECRET=whsec_...DATABASE_URL=postgres://...
```

Access in your agent:

* [  JavaScript ](#tab-panel-6685)
* [  TypeScript ](#tab-panel-6686)

JavaScript

```
class MyAgent extends Agent {  async onStart() {    const apiKey = this.env.OPENAI_API_KEY;  }}
```

TypeScript

```
class MyAgent extends Agent {  async onStart() {    const apiKey = this.env.OPENAI_API_KEY;  }}
```

### Production secrets

Use `wrangler secret` for production:

Terminal window

```
# Add a secretnpx wrangler secret put OPENAI_API_KEY# Enter value when prompted
# List secretsnpx wrangler secret list
# Delete a secretnpx wrangler secret delete OPENAI_API_KEY
```

### Non-secret variables

For non-sensitive configuration, use `vars` in the Wrangler configuration file:

* [  wrangler.jsonc ](#tab-panel-6651)
* [  wrangler.toml ](#tab-panel-6652)

JSONC

```
{  "vars": {    "API_BASE_URL": "https://api.example.com",    "MAX_RETRIES": "3",    "DEBUG_MODE": "false",  },}
```

TOML

```
[vars]API_BASE_URL = "https://api.example.com"MAX_RETRIES = "3"DEBUG_MODE = "false"
```

All values must be strings. Parse numbers and booleans in code:

* [  JavaScript ](#tab-panel-6681)
* [  TypeScript ](#tab-panel-6682)

JavaScript

```
const maxRetries = parseInt(this.env.MAX_RETRIES, 10);const debugMode = this.env.DEBUG_MODE === "true";
```

TypeScript

```
const maxRetries = parseInt(this.env.MAX_RETRIES, 10);const debugMode = this.env.DEBUG_MODE === "true";
```

### Environment-specific variables

Use `env` sections for different environments (for example, staging, production):

* [  wrangler.jsonc ](#tab-panel-6659)
* [  wrangler.toml ](#tab-panel-6660)

JSONC

```
{  "name": "my-agent",  "vars": {    "API_URL": "https://api.example.com",  },
  "env": {    "staging": {      "vars": {        "API_URL": "https://staging-api.example.com",      },    },    "production": {      "vars": {        "API_URL": "https://api.example.com",      },    },  },}
```

TOML

```
name = "my-agent"
[vars]API_URL = "https://api.example.com"
[env.staging.vars]API_URL = "https://staging-api.example.com"
[env.production.vars]API_URL = "https://api.example.com"
```

Deploy to specific environment:

Terminal window

```
npx wrangler deploy --env stagingnpx wrangler deploy --env production
```

## Local development

### Starting the dev server

With Vite (recommended for full stack apps):

Terminal window

```
npx vite dev
```

Without Vite:

Terminal window

```
npx wrangler dev
```

### Local state persistence

Durable Object state is persisted locally in `.wrangler/state/`:

* Directory.wrangler/  
  * Directorystate/  
    * Directoryv3/  
      * Directoryd1/  
        * Directoryminiflare-D1DatabaseObject/  
          * ... (SQLite files)

### Clearing local state

To reset all local Durable Object state:

Terminal window

```
rm -rf .wrangler/state
```

Or restart with fresh state:

Terminal window

```
npx wrangler dev --persist-to=""
```

### Inspecting local SQLite

You can inspect agent state directly:

Terminal window

```
# Find the SQLite filels .wrangler/state/v3/d1/
# Open with sqlite3sqlite3 .wrangler/state/v3/d1/miniflare-D1DatabaseObject/*.sqlite
```

## Dashboard setup

### Automatic resources

When you deploy, Cloudflare automatically creates:

* **Worker** \- Your deployed code
* **Durable Object namespaces** \- One per agent class
* **SQLite storage** \- Attached to each namespace

### Viewing Durable Objects

Log in to the Cloudflare dashboard, then go to Durable Objects.

[ Go to **Durable Objects** ](https://dash.cloudflare.com/?to=/:account/workers/durable-objects) 

Here you can:

* See all Durable Object namespaces
* View individual object instances
* Inspect storage (keys and values)
* Delete objects

### Real-time logs

View live logs from your agents:

Terminal window

```
npx wrangler tail
```

Or in the dashboard:

1. Go to your Worker.
2. Select the **Observability** tab.
3. Enable real-time logs.

Filter by:

* Status (success, error)
* Search text
* Sampling rate

## Production deployment

### Basic deploy

Terminal window

```
npx wrangler deploy
```

This:

1. Bundles your code
2. Uploads to Cloudflare
3. Applies migrations
4. Makes it live on `*.workers.dev`

### Custom domain

Add a route in the Wrangler configuration file:

* [  wrangler.jsonc ](#tab-panel-6653)
* [  wrangler.toml ](#tab-panel-6654)

JSONC

```
{  "routes": [    {      "pattern": "agents.example.com/*",      "zone_name": "example.com",    },  ],}
```

TOML

```
[[routes]]pattern = "agents.example.com/*"zone_name = "example.com"
```

Or use a custom domain (simpler):

* [  wrangler.jsonc ](#tab-panel-6655)
* [  wrangler.toml ](#tab-panel-6656)

JSONC

```
{  "routes": [    {      "pattern": "agents.example.com",      "custom_domain": true,    },  ],}
```

TOML

```
[[routes]]pattern = "agents.example.com"custom_domain = true
```

### Preview deployments

Deploy without affecting production:

Terminal window

```
npx wrangler deploy --dry-run    # See what would be uploadednpx wrangler versions upload     # Upload new versionnpx wrangler versions deploy     # Gradually roll out
```

### Rollbacks

Roll back to a previous version:

Terminal window

```
npx wrangler rollback
```

## Multi-environment setup

### Environment configuration

Define environments in the Wrangler configuration file:

* [  wrangler.jsonc ](#tab-panel-6683)
* [  wrangler.toml ](#tab-panel-6684)

JSONC

```
{  "name": "my-agent",  "main": "src/server.ts",
  // Base configuration (shared)  // Set this to today's date  "compatibility_date": "2026-06-30",  "compatibility_flags": ["nodejs_compat"],  "durable_objects": {    "bindings": [{ "name": "MyAgent", "class_name": "MyAgent" }],  },  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyAgent"] }],
  // Environment overrides  "env": {    "staging": {      "name": "my-agent-staging",      "vars": {        "ENVIRONMENT": "staging",      },    },    "production": {      "name": "my-agent-production",      "vars": {        "ENVIRONMENT": "production",      },    },  },}
```

TOML

```
name = "my-agent"main = "src/server.ts"# Set this to today's datecompatibility_date = "2026-06-30"compatibility_flags = [ "nodejs_compat" ]
[[durable_objects.bindings]]name = "MyAgent"class_name = "MyAgent"
[[migrations]]tag = "v1"new_sqlite_classes = [ "MyAgent" ]
[env.staging]name = "my-agent-staging"
  [env.staging.vars]  ENVIRONMENT = "staging"
[env.production]name = "my-agent-production"
  [env.production.vars]  ENVIRONMENT = "production"
```

### Deploying to environments

Terminal window

```
# Deploy to stagingnpx wrangler deploy --env staging
# Deploy to productionnpx wrangler deploy --env production
# Set secrets per environmentnpx wrangler secret put OPENAI_API_KEY --env stagingnpx wrangler secret put OPENAI_API_KEY --env production
```

### Separate Durable Objects

Each environment gets its own Durable Objects. Staging agents do not share state with production agents.

To explicitly separate:

* [  wrangler.jsonc ](#tab-panel-6663)
* [  wrangler.toml ](#tab-panel-6664)

JSONC

```
{  "env": {    "staging": {      "durable_objects": {        "bindings": [          {            "name": "MyAgent",            "class_name": "MyAgent",            "script_name": "my-agent-staging",          },        ],      },    },  },}
```

TOML

```
[[env.staging.durable_objects.bindings]]name = "MyAgent"class_name = "MyAgent"script_name = "my-agent-staging"
```

## Migrations

Migrations manage Durable Object storage schema changes.

### Adding a new agent

Add to `new_sqlite_classes` in a new migration:

* [  wrangler.jsonc ](#tab-panel-6661)
* [  wrangler.toml ](#tab-panel-6662)

JSONC

```
{  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": ["ExistingAgent"],    },    {      "tag": "v2",      "new_sqlite_classes": ["NewAgent"],    },  ],}
```

TOML

```
[[migrations]]tag = "v1"new_sqlite_classes = [ "ExistingAgent" ]
[[migrations]]tag = "v2"new_sqlite_classes = [ "NewAgent" ]
```

### Renaming an agent class

Use `renamed_classes`:

* [  wrangler.jsonc ](#tab-panel-6675)
* [  wrangler.toml ](#tab-panel-6676)

JSONC

```
{  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": ["OldName"],    },    {      "tag": "v2",      "renamed_classes": [        {          "from": "OldName",          "to": "NewName",        },      ],    },  ],}
```

TOML

```
[[migrations]]tag = "v1"new_sqlite_classes = [ "OldName" ]
[[migrations]]tag = "v2"
  [[migrations.renamed_classes]]  from = "OldName"  to = "NewName"
```

Also update:

1. The class name in code
2. The `class_name` in bindings
3. Export statements

### Deleting an agent class

Use `deleted_classes`:

* [  wrangler.jsonc ](#tab-panel-6669)
* [  wrangler.toml ](#tab-panel-6670)

JSONC

```
{  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": ["AgentToDelete", "AgentToKeep"],    },    {      "tag": "v2",      "deleted_classes": ["AgentToDelete"],    },  ],}
```

TOML

```
[[migrations]]tag = "v1"new_sqlite_classes = [ "AgentToDelete", "AgentToKeep" ]
[[migrations]]tag = "v2"deleted_classes = [ "AgentToDelete" ]
```

Warning

This permanently deletes all data for that class.

### Migration best practices

1. **Never modify existing migrations** \- Always add new ones.
2. **Use sequential tags** \- v1, v2, v3 (or use dates: 2025-01-15).
3. **Test locally first** \- Migrations run on deploy.
4. **Back up production data** \- Before renaming or deleting.

## Troubleshooting

### No such Durable Object class

The class is not in migrations:

* [  wrangler.jsonc ](#tab-panel-6665)
* [  wrangler.toml ](#tab-panel-6666)

JSONC

```
{  "migrations": [    {      "tag": "v1",      "new_sqlite_classes": ["MissingClassName"],    },  ],}
```

TOML

```
[[migrations]]tag = "v1"new_sqlite_classes = [ "MissingClassName" ]
```

### Cannot find module in types

Regenerate types:

Terminal window

```
npx wrangler types env.d.ts --include-runtime false
```

### Secrets not loading locally

Check that `.env` exists and contains the variable:

Terminal window

```
cat .env# Should show: MY_SECRET=value
```

### Migration tag conflict

Migration tags must be unique. If you see conflicts:

* [  wrangler.jsonc ](#tab-panel-6667)
* [  wrangler.toml ](#tab-panel-6668)

JSONC

```
{  // Wrong - duplicate tags  "migrations": [    { "tag": "v1", "new_sqlite_classes": ["A"] },    { "tag": "v1", "new_sqlite_classes": ["B"] },  ],}
```

TOML

```
[[migrations]]tag = "v1"new_sqlite_classes = [ "A" ]
[[migrations]]tag = "v1"new_sqlite_classes = [ "B" ]
```

* [  wrangler.jsonc ](#tab-panel-6671)
* [  wrangler.toml ](#tab-panel-6672)

JSONC

```
{  // Correct - sequential tags  "migrations": [    { "tag": "v1", "new_sqlite_classes": ["A"] },    { "tag": "v2", "new_sqlite_classes": ["B"] },  ],}
```

TOML

```
[[migrations]]tag = "v1"new_sqlite_classes = [ "A" ]
[[migrations]]tag = "v2"new_sqlite_classes = [ "B" ]
```

## Next steps

[ Agents API ](https://developers.cloudflare.com/agents/runtime/agents-api/) Complete API reference for the Agents SDK. 

[ Routing ](https://developers.cloudflare.com/agents/runtime/communication/routing/) Route requests to your agent instances. 

[ Schedule tasks ](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) Background processing with delayed and cron-based tasks.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/operations/configuration/#page","headline":"Configuration · Cloudflare Agents docs","description":"Configure Wrangler bindings, environment variables, and type generation for a project using the Agents SDK.","url":"https://developers.cloudflare.com/agents/runtime/operations/configuration/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/operations/","name":"Operations"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/operations/configuration/","name":"Configuration"}}]}
```

---

---
title: Cross-domain authentication
description: Authenticate WebSocket connections to Cloudflare Agents across domains using signed tokens.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Cross-domain authentication

When your Agents are deployed, to keep things secure, send a token from the client, then verify it on the server. This guide covers authentication patterns for WebSocket connections to agents.

## WebSocket authentication

WebSockets are not HTTP, so the handshake is limited when making cross-domain connections.

You cannot send:

* Custom headers during the upgrade
* `Authorization: Bearer ...` on connect

You can:

* Put a signed, short-lived token in the connection URL as query parameters
* Verify the token in your server's connect path

Note

Never place raw secrets in URLs. Use a JWT or a signed token that expires quickly, and is scoped to the user or room.

### Same origin

If the client and server share the origin, the browser will send cookies during the WebSocket handshake. Session-based auth can work here. Prefer HTTP-only cookies.

### Cross origin

Cookies do not help across origins. Pass credentials in the URL query, then verify on the server.

## Usage examples

### Static authentication

* [  JavaScript ](#tab-panel-6689)
* [  TypeScript ](#tab-panel-6690)

JavaScript

```
import { useAgent } from "agents/react";
function ChatComponent() {  const agent = useAgent({    agent: "my-agent",    query: {      token: "demo-token-123",      userId: "demo-user",    },  });
  // Use agent to make calls, access state, etc.}
```

TypeScript

```
import { useAgent } from "agents/react";
function ChatComponent() {  const agent = useAgent({    agent: "my-agent",    query: {      token: "demo-token-123",      userId: "demo-user",    },  });
  // Use agent to make calls, access state, etc.}
```

### Async authentication

Build query values right before connect. Use Suspense for async setup.

* [  JavaScript ](#tab-panel-6695)
* [  TypeScript ](#tab-panel-6696)

JavaScript

```
import { useAgent } from "agents/react";import { Suspense, useCallback } from "react";
function ChatComponent() {  const asyncQuery = useCallback(async () => {    const [token, user] = await Promise.all([getAuthToken(), getCurrentUser()]);    return {      token,      userId: user.id,      timestamp: Date.now().toString(),    };  }, []);
  const agent = useAgent({    agent: "my-agent",    query: asyncQuery,  });
  // Use agent to make calls, access state, etc.}
function App() {  return (    <Suspense fallback={<div>Authenticating...</div>}>      <ChatComponent />    </Suspense>  );}
```

TypeScript

```
import { useAgent } from "agents/react";import { Suspense, useCallback } from "react";
function ChatComponent() {  const asyncQuery = useCallback(async () => {    const [token, user] = await Promise.all([getAuthToken(), getCurrentUser()]);    return {      token,      userId: user.id,      timestamp: Date.now().toString(),    };  }, []);
  const agent = useAgent({    agent: "my-agent",    query: asyncQuery,  });
  // Use agent to make calls, access state, etc.}
function App() {  return (    <Suspense fallback={<div>Authenticating...</div>}>      <ChatComponent />    </Suspense>  );}
```

### JWT refresh pattern

Refresh the token when the connection fails due to authentication error.

* [  JavaScript ](#tab-panel-6697)
* [  TypeScript ](#tab-panel-6698)

JavaScript

```
import { useAgent } from "agents/react";import { useCallback } from "react";
const validateToken = async (token) => {  // An example of how you might implement this  const res = await fetch(`${API_HOST}/api/users/me`, {    headers: {      Authorization: `Bearer ${token}`,    },  });
  return res.ok;};
const refreshToken = async () => {  // Depends on implementation:  // - You could use a longer-lived token to refresh the expired token  // - De-auth the app and prompt the user to log in manually  // - ...};
function useJWTAgent(agentName) {  const asyncQuery = useCallback(async () => {    let token = localStorage.getItem("jwt");
    // If no token OR the token is no longer valid    // request a fresh token    if (!token || !(await validateToken(token))) {      token = await refreshToken();      localStorage.setItem("jwt", token);    }
    return {      token,    };  }, []);
  const agent = useAgent({    agent: agentName,    query: asyncQuery,    queryDeps: [], // Run on mount  });
  return agent;}
```

TypeScript

```
import { useAgent } from "agents/react";import { useCallback } from "react";
const validateToken = async (token: string) => {  // An example of how you might implement this  const res = await fetch(`${API_HOST}/api/users/me`, {    headers: {      Authorization: `Bearer ${token}`,    },  });
  return res.ok;};
const refreshToken = async () => {  // Depends on implementation:  // - You could use a longer-lived token to refresh the expired token  // - De-auth the app and prompt the user to log in manually  // - ...};
function useJWTAgent(agentName: string) {  const asyncQuery = useCallback(async () => {    let token = localStorage.getItem("jwt");
    // If no token OR the token is no longer valid    // request a fresh token    if (!token || !(await validateToken(token))) {      token = await refreshToken();      localStorage.setItem("jwt", token);    }
    return {      token,    };  }, []);
  const agent = useAgent({    agent: agentName,    query: asyncQuery,    queryDeps: [], // Run on mount  });
  return agent;}
```

## Cross-domain authentication

Pass credentials in the URL when connecting to another host, then verify on the server.

### Static cross-domain auth

* [  JavaScript ](#tab-panel-6691)
* [  TypeScript ](#tab-panel-6692)

JavaScript

```
import { useAgent } from "agents/react";
function StaticCrossDomainAuth() {  const agent = useAgent({    agent: "my-agent",    host: "https://my-agent.example.workers.dev",    query: {      token: "demo-token-123",      userId: "demo-user",    },  });
  // Use agent to make calls, access state, etc.}
```

TypeScript

```
import { useAgent } from "agents/react";
function StaticCrossDomainAuth() {  const agent = useAgent({    agent: "my-agent",    host: "https://my-agent.example.workers.dev",    query: {      token: "demo-token-123",      userId: "demo-user",    },  });
  // Use agent to make calls, access state, etc.}
```

### Async cross-domain auth

* [  JavaScript ](#tab-panel-6693)
* [  TypeScript ](#tab-panel-6694)

JavaScript

```
import { useAgent } from "agents/react";import { useCallback } from "react";
function AsyncCrossDomainAuth() {  const asyncQuery = useCallback(async () => {    const [token, user] = await Promise.all([getAuthToken(), getCurrentUser()]);    return {      token,      userId: user.id,      timestamp: Date.now().toString(),    };  }, []);
  const agent = useAgent({    agent: "my-agent",    host: "https://my-agent.example.workers.dev",    query: asyncQuery,  });
  // Use agent to make calls, access state, etc.}
```

TypeScript

```
import { useAgent } from "agents/react";import { useCallback } from "react";
function AsyncCrossDomainAuth() {  const asyncQuery = useCallback(async () => {    const [token, user] = await Promise.all([getAuthToken(), getCurrentUser()]);    return {      token,      userId: user.id,      timestamp: Date.now().toString(),    };  }, []);
  const agent = useAgent({    agent: "my-agent",    host: "https://my-agent.example.workers.dev",    query: asyncQuery,  });
  // Use agent to make calls, access state, etc.}
```

## Server-side verification

On the server side, verify the token in the `onConnect` handler:

* [  JavaScript ](#tab-panel-6699)
* [  TypeScript ](#tab-panel-6700)

JavaScript

```
import { Agent, Connection, ConnectionContext } from "agents";
export class SecureAgent extends Agent {  async onConnect(connection, ctx) {    const url = new URL(ctx.request.url);    const token = url.searchParams.get("token");    const userId = url.searchParams.get("userId");
    // Verify the token    if (!token || !(await this.verifyToken(token, userId))) {      connection.close(4001, "Unauthorized");      return;    }
    // Store user info on the connection state    connection.setState({ userId, authenticated: true });  }
  async verifyToken(token, userId) {    // Implement your token verification logic    // For example, verify a JWT signature, check expiration, etc.    try {      const payload = await verifyJWT(token, this.env.JWT_SECRET);      return payload.sub === userId && payload.exp > Date.now() / 1000;    } catch {      return false;    }  }
  async onMessage(connection, message) {    // Check if connection is authenticated    if (!connection.state?.authenticated) {      connection.send(JSON.stringify({ error: "Not authenticated" }));      return;    }
    // Process message for authenticated user    const userId = connection.state.userId;    // ...  }}
```

TypeScript

```
import { Agent, Connection, ConnectionContext } from "agents";
export class SecureAgent extends Agent {  async onConnect(connection: Connection, ctx: ConnectionContext) {    const url = new URL(ctx.request.url);    const token = url.searchParams.get("token");    const userId = url.searchParams.get("userId");
    // Verify the token    if (!token || !(await this.verifyToken(token, userId))) {      connection.close(4001, "Unauthorized");      return;    }
    // Store user info on the connection state    connection.setState({ userId, authenticated: true });  }
  private async verifyToken(token: string, userId: string): Promise<boolean> {    // Implement your token verification logic    // For example, verify a JWT signature, check expiration, etc.    try {      const payload = await verifyJWT(token, this.env.JWT_SECRET);      return payload.sub === userId && payload.exp > Date.now() / 1000;    } catch {      return false;    }  }
  async onMessage(connection: Connection, message: string) {    // Check if connection is authenticated    if (!connection.state?.authenticated) {      connection.send(JSON.stringify({ error: "Not authenticated" }));      return;    }
    // Process message for authenticated user    const userId = connection.state.userId;    // ...  }}
```

## Best practices

1. **Use short-lived tokens** \- Tokens in URLs may be logged. Keep expiration times short (minutes, not hours).
2. **Scope tokens appropriately** \- Include the agent name or instance in the token claims to prevent token reuse across agents.
3. **Validate on every connection** \- Always verify tokens in `onConnect`, not just once.
4. **Use HTTPS** \- Always use secure WebSocket connections (`wss://`) in production.
5. **Rotate secrets** \- Regularly rotate your JWT signing keys or token secrets.
6. **Log authentication failures** \- Track failed authentication attempts for security monitoring.

## Next steps

[ Routing ](https://developers.cloudflare.com/agents/runtime/communication/routing/) Routing and authentication hooks. 

[ WebSockets ](https://developers.cloudflare.com/agents/runtime/communication/websockets/) Real-time bidirectional communication. 

[ GitHub OAuth agent example ](https://github.com/cloudflare/agents/tree/main/examples/auth-agent) Protect an app built with Agents using GitHub OAuth, HTTP-only cookies, and server-owned Durable Object routing. 

[ Agents API ](https://developers.cloudflare.com/agents/runtime/agents-api/) Complete API reference for the Agents SDK.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/operations/cross-domain-authentication/#page","headline":"Cross-domain authentication · Cloudflare Agents docs","description":"Authenticate WebSocket connections to Cloudflare Agents across domains using signed tokens.","url":"https://developers.cloudflare.com/agents/runtime/operations/cross-domain-authentication/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/operations/","name":"Operations"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/operations/cross-domain-authentication/","name":"Cross-domain authentication"}}]}
```

---

---
title: Observability
description: Subscribe to structured Agent events for RPC calls, state changes, schedules, workflows, and MCP connections via diagnostics channels.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Observability

Agents emit structured events for every significant operation — RPC calls, state changes, schedule execution, workflow transitions, MCP connections, and more. These events are published to [diagnostics channels](https://developers.cloudflare.com/workers/runtime-apis/nodejs/diagnostics-channel/) and are silent by default (zero overhead when nobody is listening).

## Event structure

Every event has these fields:

TypeScript

```
{  type: "rpc",                        // what happened  agent: "MyAgent",                   // which agent class emitted it  name: "user-123",                   // which agent instance (Durable Object name)  payload: { method: "getWeather" },  // details  timestamp: 1758005142787            // when (ms since epoch)}
```

`agent` and `name` identify the source agent — `agent` is the class name and `name` is the Durable Object instance name.

## Channels

Events are routed to named channels based on their type:

| Channel            | Event types                                                                                                                                                         | Description                                                            |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| agents:state       | state:update                                                                                                                                                        | State sync events                                                      |
| agents:rpc         | rpc, rpc:error                                                                                                                                                      | RPC method calls and failures                                          |
| agents:message     | message:request, message:response, message:clear, message:cancel, message:error, tool:result, tool:approval, submission:create, submission:status, submission:error | Chat message, tool, and Think submission lifecycle                     |
| agents:chat        | chat:request:failed, chat:recovery:\*, chat:stream:stalled, chat:context:compacted                                                                                  | Chat request, recovery, stream-stall, and context-compaction lifecycle |
| agents:transcript  | chat:transcript:repaired                                                                                                                                            | Transcript repair events                                               |
| agents:fiber       | fiber:run:\*, fiber:recovery:\*                                                                                                                                     | Durable fiber lifecycle                                                |
| agents:agent\_tool | agent\_tool:recovery:\*                                                                                                                                             | Parent/child agent-tool recovery                                       |
| agents:schedule    | schedule:create, schedule:execute, schedule:cancel, schedule:retry, schedule:error, schedule:duplicate\_warning, queue:create, queue:retry, queue:error             | Scheduled and queued task lifecycle                                    |
| agents:lifecycle   | connect, disconnect, destroy                                                                                                                                        | Agent connection and teardown                                          |
| agents:workflow    | workflow:start, workflow:event, workflow:approved, workflow:rejected, workflow:terminated, workflow:paused, workflow:resumed, workflow:restarted                    | Workflow state transitions                                             |
| agents:mcp         | mcp:client:preconnect, mcp:client:connect, mcp:client:authorize, mcp:client:discover                                                                                | MCP client operations                                                  |
| agents:email       | email:receive, email:reply, email:send                                                                                                                              | Email processing                                                       |

## Subscribing to events

### Typed subscribe helper

The `subscribe()` function from `agents/observability` provides type-safe access to events on a specific channel:

* [  JavaScript ](#tab-panel-6705)
* [  TypeScript ](#tab-panel-6706)

JavaScript

```
import { subscribe } from "agents/observability";
const unsub = subscribe("rpc", (event) => {  if (event.type === "rpc") {    console.log(`RPC call: ${event.payload.method}`);  }  if (event.type === "rpc:error") {    console.error(      `RPC failed: ${event.payload.method} — ${event.payload.error}`,    );  }});
// Clean up when doneunsub();
```

TypeScript

```
import { subscribe } from "agents/observability";
const unsub = subscribe("rpc", (event) => {  if (event.type === "rpc") {    console.log(`RPC call: ${event.payload.method}`);  }  if (event.type === "rpc:error") {    console.error(      `RPC failed: ${event.payload.method} — ${event.payload.error}`,    );  }});
// Clean up when doneunsub();
```

The callback is fully typed — `event` is narrowed to only the event types that flow through that channel.

The typed helper uses camelCase keys, so agent-tool recovery is `subscribe("agentTool", ...)`. Raw diagnostics channel subscribers should use the emitted channel name, `agents:agent_tool`.

### Raw diagnostics\_channel

You can also subscribe directly using the Node.js API:

* [  JavaScript ](#tab-panel-6701)
* [  TypeScript ](#tab-panel-6702)

JavaScript

```
import { subscribe } from "node:diagnostics_channel";
subscribe("agents:schedule", (event) => {  console.log(event);});
```

TypeScript

```
import { subscribe } from "node:diagnostics_channel";
subscribe("agents:schedule", (event) => {  console.log(event);});
```

## Tail Workers (production)

In production, all diagnostics channel messages are automatically forwarded to [Tail Workers](https://developers.cloudflare.com/workers/observability/logs/tail-workers/). No subscription code is needed in the agent itself — attach a Tail Worker and access events via `event.diagnosticsChannelEvents`:

* [  JavaScript ](#tab-panel-6707)
* [  TypeScript ](#tab-panel-6708)

JavaScript

```
export default {  async tail(events) {    for (const event of events) {      for (const msg of event.diagnosticsChannelEvents) {        // msg.channel is "agents:rpc", "agents:workflow", etc.        // msg.message is the typed event payload        console.log(msg.timestamp, msg.channel, msg.message);      }    }  },};
```

TypeScript

```
export default {  async tail(events) {    for (const event of events) {      for (const msg of event.diagnosticsChannelEvents) {        // msg.channel is "agents:rpc", "agents:workflow", etc.        // msg.message is the typed event payload        console.log(msg.timestamp, msg.channel, msg.message);      }    }  },};
```

This gives you structured, filterable observability in production with zero overhead in the agent hot path.

## Custom observability

You can override the default implementation by providing your own `Observability` interface:

* [  JavaScript ](#tab-panel-6709)
* [  TypeScript ](#tab-panel-6710)

JavaScript

```
import { Agent } from "agents";
const myObservability = {  emit(event) {    // Send to your logging service, filter events, etc.    if (event.type === "rpc:error") {      console.error(event.payload.method, event.payload.error);    }  },};
class MyAgent extends Agent {  observability = myObservability;}
```

TypeScript

```
import { Agent } from "agents";import type { Observability } from "agents/observability";
const myObservability: Observability = {  emit(event) {    // Send to your logging service, filter events, etc.    if (event.type === "rpc:error") {      console.error(event.payload.method, event.payload.error);    }  },};
class MyAgent extends Agent {  override observability = myObservability;}
```

Set `observability` to `undefined` to disable all event emission:

* [  JavaScript ](#tab-panel-6703)
* [  TypeScript ](#tab-panel-6704)

JavaScript

```
import { Agent } from "agents";
class MyAgent extends Agent {  observability = undefined;}
```

TypeScript

```
import { Agent } from "agents";
class MyAgent extends Agent {  override observability = undefined;}
```

## Event reference

### RPC events

| Type      | Payload                | When                          |
| --------- | ---------------------- | ----------------------------- |
| rpc       | { method, streaming? } | A @callable method is invoked |
| rpc:error | { method, error }      | A @callable method throws     |

### State events

| Type         | Payload | When                 |
| ------------ | ------- | -------------------- |
| state:update | {}      | setState() is called |

### Message, tool, and submission events

These events track chat message lifecycle, client-side tool interactions, and Think durable submissions.

| Type              | Payload                  | When                                |
| ----------------- | ------------------------ | ----------------------------------- |
| message:request   | {}                       | A chat message is received          |
| message:response  | {}                       | A chat response stream completes    |
| message:clear     | {}                       | Chat history is cleared             |
| message:cancel    | { requestId }            | A streaming request is cancelled    |
| message:error     | { error }                | A chat stream fails                 |
| tool:result       | { toolCallId, toolName } | A client tool result is received    |
| tool:approval     | { toolCallId, approved } | A tool call is approved or rejected |
| submission:create | { submissionId }         | A Think submission is accepted      |
| submission:status | { submissionId, status } | A Think submission status changes   |
| submission:error  | { submissionId, error }  | A Think submission fails            |

### Chat recovery events

| Type                    | Payload                                                                | When                                                                                                                                         |
| ----------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| chat:request:failed     | { requestId?, stage, messagesPersisted?, error }                       | A Think chat request fails while parsing, persisting, running, or streaming                                                                  |
| chat:recovery:detected  | { incidentId, requestId, attempt, maxAttempts, recoveryKind }          | An interrupted chat fiber is first observed                                                                                                  |
| chat:recovery:attempt   | { incidentId, requestId, attempt, maxAttempts, recoveryKind }          | The framework begins a recovery attempt                                                                                                      |
| chat:recovery:scheduled | { incidentId, requestId, attempt, maxAttempts, recoveryKind }          | A retry or continuation callback is scheduled                                                                                                |
| chat:recovery:completed | { incidentId, requestId, attempt, maxAttempts, recoveryKind }          | Recovery completed successfully                                                                                                              |
| chat:recovery:skipped   | { incidentId, requestId, attempt, maxAttempts, recoveryKind, reason? } | Recovery was skipped because the conversation changed or was no longer recoverable                                                           |
| chat:recovery:failed    | { incidentId, requestId, attempt, maxAttempts, recoveryKind, reason? } | Recovery ran but failed                                                                                                                      |
| chat:recovery:exhausted | { incidentId, requestId, attempt, maxAttempts, recoveryKind, reason }  | Recovery exceeded its configured attempt budget                                                                                              |
| chat:stream:stalled     | { requestId, timeoutMs }                                               | The inactivity watchdog fired — no stream chunk arrived within chatStreamStallTimeoutMs. With chatRecovery on, the turn routes into recovery |

`recoveryKind` is `"retry"` when recovery replays an unanswered user turn and `"continue"` when it continues a partial assistant turn.

### Chat context events

| Type                   | Payload                                     | When                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| ---------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| chat:context:compacted | { reason, shortened, requestId?, attempt? } | Think compacts the session to handle a context-window overflow. reason is "proactive" (the contextOverflow.proactive guard fired before a step) or "reactive" (contextOverflow.reactive fired after an overflow). shortened is whether compaction actually reduced history — false means a retry would overflow again. Refer to [Context-window overflow recovery](https://developers.cloudflare.com/agents/harnesses/think/recovery/#context-window-overflow-recovery). |

### Transcript events

| Type                     | Payload                                                          | When                                                                                                                                                                                            |
| ------------------------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| chat:transcript:repaired | { requestId?, removedToolCalls, normalizedInputs, toolCallIds? } | Think repairs a persisted transcript before sending it to the provider. removedToolCalls counts orphaned tool calls healed; normalizedInputs counts stringified or missing tool inputs repaired |

### Fiber events

| Type                    | Payload                                                              | When                                 |
| ----------------------- | -------------------------------------------------------------------- | ------------------------------------ |
| fiber:run:started       | { fiberId, fiberName, managed? }                                     | A durable fiber starts               |
| fiber:run:completed     | { fiberId, fiberName, managed?, elapsedMs? }                         | A durable fiber completes            |
| fiber:run:failed        | { fiberId, fiberName, managed?, error, elapsedMs? }                  | A durable fiber throws               |
| fiber:run:interrupted   | { fiberId, fiberName, managed?, recoveryReason, elapsedMs? }         | Startup finds an interrupted fiber   |
| fiber:recovery:detected | { fiberId, fiberName, managed?, recoveryReason, elapsedMs? }         | Recovery sees an interrupted fiber   |
| fiber:recovery:attempt  | { fiberId, fiberName, managed?, recoveryReason }                     | A recovery hook starts               |
| fiber:recovery:handled  | { fiberId, fiberName, managed?, recoveryReason, status, elapsedMs? } | Recovery handling completes          |
| fiber:recovery:skipped  | { fiberId, fiberName, managed?, reason, elapsedMs? }                 | A recovery scan skips remaining work |
| fiber:recovery:failed   | { fiberId, fiberName, managed?, error, reason?, elapsedMs? }         | A recovery hook fails                |

### Agent-tool recovery events

| Type                          | Payload                                           | When                                                         |
| ----------------------------- | ------------------------------------------------- | ------------------------------------------------------------ |
| agent\_tool:recovery:begin    | { runCount, totalTimeoutMs? }                     | Parent recovery starts scanning stale agent-tool runs        |
| agent\_tool:recovery:row      | { runId, agentType, status, reason?, elapsedMs? } | One stale run is reconciled                                  |
| agent\_tool:recovery:deadline | { runId, agentType, elapsedMs? }                  | Total recovery deadline is exhausted before inspecting a row |
| agent\_tool:recovery:complete | { runCount, elapsedMs? }                          | Parent recovery finishes scanning rows                       |
| agent\_tool:recovery:failed   | { error }                                         | Parent recovery fails unexpectedly                           |

### Schedule and queue events

| Type                        | Payload                                | When                                         |
| --------------------------- | -------------------------------------- | -------------------------------------------- |
| schedule:create             | { callback, id }                       | A schedule is created                        |
| schedule:execute            | { callback, id }                       | A scheduled callback starts                  |
| schedule:cancel             | { callback, id }                       | A schedule is cancelled                      |
| schedule:retry              | { callback, id, attempt, maxAttempts } | A scheduled callback is retried              |
| schedule:error              | { callback, id, error, attempts }      | A scheduled callback fails after all retries |
| schedule:duplicate\_warning | { callback }                           | A non-idempotent schedule may duplicate work |
| queue:create                | { callback, id }                       | A task is enqueued                           |
| queue:retry                 | { callback, id, attempt, maxAttempts } | A queued callback is retried                 |
| queue:error                 | { callback, id, error, attempts }      | A queued callback fails after all retries    |

### Lifecycle events

| Type       | Payload                        | When                                  |
| ---------- | ------------------------------ | ------------------------------------- |
| connect    | { connectionId }               | A WebSocket connection is established |
| disconnect | { connectionId, code, reason } | A WebSocket connection is closed      |
| destroy    | {}                             | The agent is destroyed                |

### Workflow events

| Type                | Payload                       | When                           |
| ------------------- | ----------------------------- | ------------------------------ |
| workflow:start      | { workflowId, workflowName? } | A workflow instance is started |
| workflow:event      | { workflowId, eventType? }    | An event is sent to a workflow |
| workflow:approved   | { workflowId, reason? }       | A workflow is approved         |
| workflow:rejected   | { workflowId, reason? }       | A workflow is rejected         |
| workflow:terminated | { workflowId, workflowName? } | A workflow is terminated       |
| workflow:paused     | { workflowId, workflowName? } | A workflow is paused           |
| workflow:resumed    | { workflowId, workflowName? } | A workflow is resumed          |
| workflow:restarted  | { workflowId, workflowName? } | A workflow is restarted        |

### MCP events

| Type                  | Payload                               | When                                         |
| --------------------- | ------------------------------------- | -------------------------------------------- |
| mcp:client:preconnect | { serverId }                          | Before connecting to an MCP server           |
| mcp:client:connect    | { url, transport, state, error? }     | An MCP connection attempt completes or fails |
| mcp:client:authorize  | { serverId, authUrl, clientId? }      | An MCP OAuth flow begins                     |
| mcp:client:discover   | { url?, state?, error?, capability? } | MCP capability discovery succeeds or fails   |

### Email events

| Type          | Payload                | When                  |
| ------------- | ---------------------- | --------------------- |
| email:receive | { from, to, subject? } | An email is received  |
| email:reply   | { from, to, subject? } | A reply email is sent |
| email:send    | { from, to, subject? } | An email is sent      |

## Next steps

[ Configuration ](https://developers.cloudflare.com/agents/runtime/operations/configuration/) wrangler.jsonc setup and deployment. 

[ Tail Workers ](https://developers.cloudflare.com/workers/observability/logs/tail-workers/) Forward diagnostics channel events to a Tail Worker for production monitoring. 

[ Agents API ](https://developers.cloudflare.com/agents/runtime/agents-api/) Complete API reference for the Agents SDK.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/operations/observability/#page","headline":"Observability · Cloudflare Agents docs","description":"Subscribe to structured Agent events for RPC calls, state changes, schedules, workflows, and MCP connections via diagnostics channels.","url":"https://developers.cloudflare.com/agents/runtime/operations/observability/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-05","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/operations/","name":"Operations"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/operations/observability/","name":"Observability"}}]}
```

---

---
title: Using AI Models
description: Call AI models from Workers AI, OpenAI, Anthropic, Google Gemini, or any provider within Cloudflare Agents.
image: https://developers.cloudflare.com/dev-products-preview.png
---

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

[Skip to content](#%5Ftop) 

# Using AI Models

Agents can call AI models from any provider. [Workers AI](https://developers.cloudflare.com/workers-ai/) is built in and requires no API keys. You can also use [OpenAI ↗](https://platform.openai.com/docs/quickstart?language=javascript), [Anthropic ↗](https://docs.anthropic.com/en/api/client-sdks#typescript), [Google Gemini ↗](https://ai.google.dev/gemini-api/docs/openai), or any service that exposes an OpenAI-compatible API.

The [AI SDK ↗](https://ai-sdk.dev/docs/introduction) provides a unified interface across all of these providers, and is what `AIChatAgent` and the starter template use under the hood. You can also use the model routing features in [AI Gateway](https://developers.cloudflare.com/ai-gateway/) to route across providers, eval responses, and manage rate limits.

## Calling AI Models

You can call models from any method within an Agent, including from HTTP requests using the [onRequest](https://developers.cloudflare.com/agents/runtime/agents-api/) handler, when a [scheduled task](https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/) runs, when handling a WebSocket message in the [onMessage](https://developers.cloudflare.com/agents/runtime/communication/websockets/) handler, or from any of your own methods.

Agents can call AI models on their own — autonomously — and can handle long-running responses that take minutes (or longer) to respond in full. If a client disconnects mid-stream, the Agent keeps running and can catch the client up when it reconnects.

### Streaming over WebSockets

Modern reasoning models can take some time to both generate a response _and_ stream the response back to the client. Instead of buffering the entire response, you can stream it back over [WebSockets](https://developers.cloudflare.com/agents/runtime/communication/websockets/).

* [  JavaScript ](#tab-panel-6723)
* [  TypeScript ](#tab-panel-6724)

src/index.js

```
import { Agent } from "agents";import { streamText } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class MyAgent extends Agent {  async onConnect(connection, ctx) {    //  }
  async onMessage(connection, message) {    let msg = JSON.parse(message);    await this.queryReasoningModel(connection, msg.prompt);  }
  async queryReasoningModel(connection, userPrompt) {    try {      const workersai = createWorkersAI({ binding: this.env.AI });      const result = streamText({        model: workersai("@cf/zai-org/glm-4.7-flash"),        prompt: userPrompt,      });
      for await (const chunk of result.textStream) {        if (chunk) {          connection.send(JSON.stringify({ type: "chunk", content: chunk }));        }      }
      connection.send(JSON.stringify({ type: "done" }));    } catch (error) {      connection.send(JSON.stringify({ type: "error", error: error }));    }  }}
```

src/index.ts

```
import { Agent } from "agents";import { streamText } from "ai";import { createWorkersAI } from "workers-ai-provider";
interface Env {  AI: Ai;}
export class MyAgent extends Agent<Env> {  async onConnect(connection: Connection, ctx: ConnectionContext) {    //  }
  async onMessage(connection: Connection, message: WSMessage) {    let msg = JSON.parse(message);    await this.queryReasoningModel(connection, msg.prompt);  }
  async queryReasoningModel(connection: Connection, userPrompt: string) {    try {      const workersai = createWorkersAI({ binding: this.env.AI });      const result = streamText({        model: workersai("@cf/zai-org/glm-4.7-flash"),        prompt: userPrompt,      });
      for await (const chunk of result.textStream) {        if (chunk) {          connection.send(JSON.stringify({ type: "chunk", content: chunk }));        }      }
      connection.send(JSON.stringify({ type: "done" }));    } catch (error) {      connection.send(JSON.stringify({ type: "error", error: error }));    }  }}
```

You can also persist AI model responses back to [Agent state](https://developers.cloudflare.com/agents/runtime/lifecycle/state/) using `this.setState`. If a user disconnects, read the message history back and send it to the user when they reconnect.

## Workers AI

You can use [any of the models available in Workers AI](https://developers.cloudflare.com/workers-ai/models/) within your Agent by [configuring a binding](https://developers.cloudflare.com/workers-ai/configuration/bindings/). No API keys are required.

Workers AI supports streaming responses by setting `stream: true`. Use streaming to avoid buffering and delaying responses, especially for larger models or reasoning models.

* [  JavaScript ](#tab-panel-6717)
* [  TypeScript ](#tab-panel-6718)

src/index.js

```
import { Agent } from "agents";
export class MyAgent extends Agent {  async onRequest(request) {    const stream = await this.env.AI.run(      "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",      {        prompt: "Build me a Cloudflare Worker that returns JSON.",        stream: true,      },    );
    return new Response(stream, {      headers: { "content-type": "text/event-stream" },    });  }}
```

src/index.ts

```
import { Agent } from "agents";
interface Env {  AI: Ai;}
export class MyAgent extends Agent<Env> {  async onRequest(request: Request) {    const stream = await this.env.AI.run(      "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",      {        prompt: "Build me a Cloudflare Worker that returns JSON.",        stream: true,      },    );
    return new Response(stream, {      headers: { "content-type": "text/event-stream" },    });  }}
```

Your Wrangler configuration needs an `ai` binding:

* [  wrangler.jsonc ](#tab-panel-6711)
* [  wrangler.toml ](#tab-panel-6712)

JSONC

```
{  "ai": {    "binding": "AI",  },}
```

TOML

```
[ai]binding = "AI"
```

### Model routing

You can use [AI Gateway](https://developers.cloudflare.com/ai-gateway/) directly from an Agent by specifying a [gateway configuration](https://developers.cloudflare.com/ai-gateway/usage/providers/workersai/) when calling the AI binding. Model routing lets you route requests across providers based on availability, rate limits, or cost budgets.

* [  JavaScript ](#tab-panel-6721)
* [  TypeScript ](#tab-panel-6722)

src/index.js

```
import { Agent } from "agents";
export class MyAgent extends Agent {  async onRequest(request) {    const response = await this.env.AI.run(      "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",      {        prompt: "Build me a Cloudflare Worker that returns JSON.",      },      {        gateway: {          id: "{gateway_id}",          skipCache: false,          cacheTtl: 3360,        },      },    );
    return Response.json(response);  }}
```

src/index.ts

```
import { Agent } from "agents";
interface Env {  AI: Ai;}
export class MyAgent extends Agent<Env> {  async onRequest(request: Request) {    const response = await this.env.AI.run(      "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",      {        prompt: "Build me a Cloudflare Worker that returns JSON.",      },      {        gateway: {          id: "{gateway_id}",          skipCache: false,          cacheTtl: 3360,        },      },    );
    return Response.json(response);  }}
```

The `ai` binding in your Wrangler configuration is shared across both Workers AI and AI Gateway.

* [  wrangler.jsonc ](#tab-panel-6713)
* [  wrangler.toml ](#tab-panel-6714)

JSONC

```
{  "ai": {    "binding": "AI",  },}
```

TOML

```
[ai]binding = "AI"
```

Visit the [AI Gateway documentation](https://developers.cloudflare.com/ai-gateway/) to learn how to configure a gateway and retrieve a gateway ID.

## AI SDK

The [AI SDK ↗](https://ai-sdk.dev/docs/introduction) provides a unified API for text generation, tool calling, structured responses, and more. It works with any provider that has an AI SDK adapter, including Workers AI via [workers-ai-provider ↗](https://www.npmjs.com/package/workers-ai-provider).

 npm  yarn  pnpm  bun 

```
npm i ai workers-ai-provider
```

```
yarn add ai workers-ai-provider
```

```
pnpm add ai workers-ai-provider
```

```
bun add ai workers-ai-provider
```

* [  JavaScript ](#tab-panel-6719)
* [  TypeScript ](#tab-panel-6720)

src/index.js

```
import { Agent } from "agents";import { generateText } from "ai";import { createWorkersAI } from "workers-ai-provider";
export class MyAgent extends Agent {  async onRequest(request) {    const workersai = createWorkersAI({ binding: this.env.AI });    const { text } = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: "Build me an AI agent on Cloudflare Workers",    });
    return Response.json({ modelResponse: text });  }}
```

src/index.ts

```
import { Agent } from "agents";import { generateText } from "ai";import { createWorkersAI } from "workers-ai-provider";
interface Env {  AI: Ai;}
export class MyAgent extends Agent<Env> {  async onRequest(request: Request): Promise<Response> {    const workersai = createWorkersAI({ binding: this.env.AI });    const { text } = await generateText({      model: workersai("@cf/zai-org/glm-4.7-flash"),      prompt: "Build me an AI agent on Cloudflare Workers",    });
    return Response.json({ modelResponse: text });  }}
```

You can swap the provider to use OpenAI, Anthropic, or any other AI SDK-compatible adapter:

 npm  yarn  pnpm  bun 

```
npm i ai @ai-sdk/openai
```

```
yarn add ai @ai-sdk/openai
```

```
pnpm add ai @ai-sdk/openai
```

```
bun add ai @ai-sdk/openai
```

* [  JavaScript ](#tab-panel-6715)
* [  TypeScript ](#tab-panel-6716)

src/index.js

```
import { Agent } from "agents";import { generateText } from "ai";import { openai } from "@ai-sdk/openai";
export class MyAgent extends Agent {  async onRequest(request) {    const { text } = await generateText({      model: openai("gpt-4o"),      prompt: "Build me an AI agent on Cloudflare Workers",    });
    return Response.json({ modelResponse: text });  }}
```

src/index.ts

```
import { Agent } from "agents";import { generateText } from "ai";import { openai } from "@ai-sdk/openai";
export class MyAgent extends Agent {  async onRequest(request: Request): Promise<Response> {    const { text } = await generateText({      model: openai("gpt-4o"),      prompt: "Build me an AI agent on Cloudflare Workers",    });
    return Response.json({ modelResponse: text });  }}
```

## OpenAI-compatible endpoints

Agents can call models across any service that supports the OpenAI API. For example, you can use the OpenAI SDK to call one of [Google's Gemini models ↗](https://ai.google.dev/gemini-api/docs/openai#node.js) directly from your Agent.

Agents can stream responses back over HTTP using Server-Sent Events (SSE) from within an `onRequest` handler, or by using the native [WebSocket API](https://developers.cloudflare.com/agents/runtime/communication/websockets/) to stream responses back to a client.

* [  JavaScript ](#tab-panel-6725)
* [  TypeScript ](#tab-panel-6726)

src/index.js

```
import { Agent } from "agents";import { OpenAI } from "openai";
export class MyAgent extends Agent {  async onRequest(request) {    const client = new OpenAI({      apiKey: this.env.GEMINI_API_KEY,      baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/",    });
    let { readable, writable } = new TransformStream();    let writer = writable.getWriter();    const textEncoder = new TextEncoder();
    this.ctx.waitUntil(      (async () => {        const stream = await client.chat.completions.create({          model: "gemini-2.0-flash",          messages: [            { role: "user", content: "Write me a Cloudflare Worker." },          ],          stream: true,        });
        for await (const part of stream) {          writer.write(            textEncoder.encode(part.choices[0]?.delta?.content || ""),          );        }        writer.close();      })(),    );
    return new Response(readable);  }}
```

src/index.ts

```
import { Agent } from "agents";import { OpenAI } from "openai";
export class MyAgent extends Agent {  async onRequest(request: Request): Promise<Response> {    const client = new OpenAI({      apiKey: this.env.GEMINI_API_KEY,      baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/",    });
    let { readable, writable } = new TransformStream();    let writer = writable.getWriter();    const textEncoder = new TextEncoder();
    this.ctx.waitUntil(      (async () => {        const stream = await client.chat.completions.create({          model: "gemini-2.0-flash",          messages: [            { role: "user", content: "Write me a Cloudflare Worker." },          ],          stream: true,        });
        for await (const part of stream) {          writer.write(            textEncoder.encode(part.choices[0]?.delta?.content || ""),          );        }        writer.close();      })(),    );
    return new Response(readable);  }}
```

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/operations/using-ai-models/#page","headline":"Using AI Models · Cloudflare Agents docs","description":"Call AI models from Workers AI, OpenAI, Anthropic, Google Gemini, or any provider within Cloudflare Agents.","url":"https://developers.cloudflare.com/agents/runtime/operations/using-ai-models/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI"]}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/runtime/","name":"Runtime"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/runtime/operations/","name":"Operations"}},{"@type":"ListItem","position":5,"item":{"@id":"/agents/runtime/operations/using-ai-models/","name":"Using AI Models"}}]}
```
