---
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://edgetunnel-b2h.pages.dev/dev-products-preview.png
---

> Documentation Index  
> Fetch the complete documentation index at: https://edgetunnel-b2h.pages.dev/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://edgetunnel-b2h.pages.dev/agents/harnesses/think/getting-started/)) is all you need.

For durable acceptance with idempotent retry and later status inspection, refer to [Programmatic submissions](https://edgetunnel-b2h.pages.dev/agents/harnesses/think/programmatic-submissions/). For recovery after eviction, refer to [Durable recovery](https://edgetunnel-b2h.pages.dev/agents/harnesses/think/recovery/).

## chat

**TypeScript**

```ts
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-6159)
* [  TypeScript ](#tab-panel-6160)

**JavaScript**

```js
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**

```ts
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-6155)
* [  TypeScript ](#tab-panel-6156)

**JavaScript**

```js
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**

```ts
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-6149)
* [  TypeScript ](#tab-panel-6150)

**JavaScript**

```js
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000);


await child.chat("Long analysis task", callback, {
  signal: controller.signal,
});
```

**TypeScript**

```ts
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**

```ts
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-6151)
* [  TypeScript ](#tab-panel-6152)

**JavaScript**

```js
await this.saveMessages([
  {
    id: crypto.randomUUID(),
    role: "user",
    parts: [{ type: "text", text: "Time for your daily summary." }],
  },
]);
```

**TypeScript**

```ts
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-6153)
* [  TypeScript ](#tab-panel-6154)

**JavaScript**

```js
await this.saveMessages((current) => [
  ...current,
  {
    id: crypto.randomUUID(),
    role: "user",
    parts: [{ type: "text", text: "Continue your analysis." }],
  },
]);
```

**TypeScript**

```ts
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://edgetunnel-b2h.pages.dev/agents/harnesses/think/scheduled-tasks/):

* [  JavaScript ](#tab-panel-6157)
* [  TypeScript ](#tab-panel-6158)

**JavaScript**

```js
export class MyAgent extends Think {
  getModel() {
    /* ... */
  }


  getScheduledTasks() {
    return {
      dailyReport: {
        schedule: "every day at 09:00",
        timezone: "UTC",
        prompt: "Generate the daily report.",
      },
    };
  }
}
```

**TypeScript**

```ts
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**

```ts
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**

```ts
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**

```ts
protected abortRequest(requestId: string, reason?: unknown): void
protected 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://edgetunnel-b2h.pages.dev/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://edgetunnel-b2h.pages.dev/agents/harnesses/think/sub-agents/","inLanguage":"en","image":"https://edgetunnel-b2h.pages.dev/dev-products-preview.png","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://edgetunnel-b2h.pages.dev/#website","name":"Cloudflare Docs","url":"https://edgetunnel-b2h.pages.dev/"}}
{"@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"}}]}
```
