Autonomous responses

Last updated Aug 20, 2026 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:

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,
});
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() 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().

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.

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.
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.

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.
    }
}
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 for more on schedule() and cron syntax.

Processing a queue

When you control the trigger, a simple loop is the clearest pattern:

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

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

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:

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:

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

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 }));
        }
    }
}
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

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);
    }
}