---
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"}}]}
```
