--- title: "Sub-Agents" sidebarTitle: "Sub-Agents" description: "Delegate work to durable sub-agents from within a parent agent's tool calls, with streaming preliminary results." --- Sub-agents let a parent agent delegate work to other agents running as durable Trigger.dev tasks. The sub-agent's response streams back through the parent as preliminary tool results, so the frontend sees the sub-agent working inside the parent's tool call card. This builds on the AI SDK's [async generator tool pattern](https://ai-sdk.dev/docs/agents/subagents) and Trigger.dev's [AgentChat](/ai-chat/server-chat) for server-side agent interaction. ## How it works 1. The parent LLM calls a tool (e.g., `researchAgent`) 2. The tool's `execute` is an `async function*` (async generator) 3. Inside, it creates an `AgentChat` and sends a message to the sub-agent 4. `yield* stream.messages()` streams each accumulated `UIMessage` snapshot as a preliminary tool result 5. The frontend renders the sub-agent's response building up inside the parent's tool card 6. `toModelOutput` compresses the full output into a summary for the parent LLM ``` Parent LLM │ ├─ calls researchAgent tool │ │ │ ├─ AgentChat triggers sub-agent run │ ├─ sub-agent streams response (text, tool calls, etc.) │ ├─ yield* sends UIMessage snapshots as preliminary results │ └─ toModelOutput compresses for parent LLM │ └─ parent LLM reads compressed summary, continues reasoning ``` ## Single-turn sub-agent The simplest pattern: one tool call, one sub-agent turn, conversation closes. ```ts import { tool, stepCountIs } from "ai"; import { AgentChat } from "@trigger.dev/sdk/chat"; import { z } from "zod"; import type { prReviewAgent } from "./trigger/pr-review"; const prReviewTool = tool({ description: "Delegate a PR review to the PR review agent.", inputSchema: z.object({ prNumber: z.number().describe("The PR number to review"), repo: z.string().describe("The GitHub repo URL"), }), execute: async function* ({ prNumber, repo }, { abortSignal }) { const chat = new AgentChat({ agent: "pr-review", id: `review-${prNumber}`, clientData: { userId: "parent-agent", githubUrl: repo }, }); const stream = await chat.sendMessage(`Review PR #${prNumber}`, { abortSignal }); // Each yield sends a UIMessage snapshot to the frontend yield* stream.messages(); await chat.close(); }, // The parent LLM only sees this compressed summary toModelOutput: ({ output: message }) => { const lastText = message?.parts?.findLast( (p: { type: string }) => p.type === "text" ) as { text?: string } | undefined; return { type: "text", value: lastText?.text ?? "Review complete." }; }, }); ``` Use this tool in a parent agent's `streamText` call: ```ts import { streamText } from "ai"; import { anthropic } from "@ai-sdk/anthropic"; const result = streamText({ model: anthropic("claude-sonnet-4-6"), tools: { prReview: prReviewTool }, prompt: "Review PR #42 on triggerdotdev/trigger.dev", stopWhen: stepCountIs(15), }); ``` ## Multi-turn sub-agent (LLM-driven) The parent LLM drives a persistent conversation with a sub-agent across multiple tool calls. Each call with the same `conversationId` hits the same durable agent run. ```ts import { tool } from "ai"; import { AgentChat } from "@trigger.dev/sdk/chat"; import { z } from "zod"; // Track active sub-agent conversations const subAgents = new Map(); const researchTool = tool({ description: "Talk to a research agent. Use the same conversationId to continue " + "an existing conversation — the agent remembers full context.", inputSchema: z.object({ conversationId: z .string() .describe("Unique ID for this research thread. Reuse to continue."), message: z.string().describe("Your message to the research agent"), }), execute: async function* ({ conversationId, message }, { abortSignal }) { let agent = subAgents.get(conversationId); if (!agent) { agent = new AgentChat({ agent: "research-agent", id: conversationId, }); subAgents.set(conversationId, agent); } const stream = await agent.sendMessage(message, { abortSignal }); yield* stream.messages(); }, toModelOutput: ({ output: message }) => { const lastText = message?.parts?.findLast( (p: { type: string }) => p.type === "text" ) as { text?: string } | undefined; return { type: "text", value: lastText?.text ?? "Done." }; }, }); ``` The parent LLM naturally calls this tool multiple times: 1. `researchAgent({ conversationId: "competitors", message: "Research competitors in AI agents" })` — first call triggers a new sub-agent run 2. `researchAgent({ conversationId: "competitors", message: "Go deeper on pricing" })` — same run, sub-agent has full context 3. `researchAgent({ conversationId: "new-topic", message: "..." })` — different ID = different sub-agent ### Cross-turn persistence Sub-agent conversations persist across **parent turns** because the `Map` lives in the parent's process heap. When the parent suspends and restores via snapshot, the heap is preserved — the Map still has the conversations, the sessions still have the run IDs. ```ts export const orchestrator = chat .withClientData({ schema: z.object({ userId: z.string() }) }) .customAgent({ id: "orchestrator", run: async (payload, { signal: runSignal }) => { // These survive across parent turns via snapshot/restore const subAgents = new Map(); const researchTool = tool({ // ... closes over subAgents Map }); // Turn loop — subAgents persist across all turns for (let turn = 0; turn < 50; turn++) { // ... streamText with researchTool } // Cleanup when parent exits await Promise.all( Array.from(subAgents.values()).map((a) => a.close().catch(() => {})) ); }, }); ``` ## How sub-agents clean up Sub-agents clean up through three mechanisms: 1. **Explicit close**: Call `chat.close()` or `agent.close()` when done 2. **Idle timeout**: The sub-agent's idle timeout expires, it suspends 3. **Suspend timeout**: The sub-agent's suspend timeout expires, the run ends For the multi-turn pattern, the parent should clean up sub-agents when it exits (in `onComplete` for managed agents, or at the end of the loop for custom agents). Without explicit cleanup, sub-agents close on their own via timeouts — no leaked resources or cost while suspended. ## What the frontend sees Each `yield` from `stream.messages()` sends a complete `UIMessage` containing all the sub-agent's parts accumulated so far. The AI SDK delivers these as `tool-output-available` chunks with `preliminary: true`. The frontend renders the tool part with: - `state: "output-available"` and `preliminary: true` while streaming - `state: "output-available"` and `preliminary: false` (or absent) when done The tool output contains the full `UIMessage` with nested parts — text, the sub-agent's own tool calls and results, reasoning, etc. ### Controlling what the parent LLM sees `toModelOutput` transforms the tool's output before it enters the parent LLM's context. The full UIMessage streams to the frontend, but the model only sees the compressed version: ```ts toModelOutput: ({ output: message }) => { // Extract just the final text — the model doesn't need // to see all the sub-agent's tool calls and intermediate work const lastText = message?.parts?.findLast( (p: { type: string }) => p.type === "text" ) as { text?: string } | undefined; return { type: "text", value: lastText?.text ?? "Done." }; }, ``` This is important for token efficiency: the sub-agent might use 100K tokens exploring and reasoning, but the parent LLM only consumes the summary. `toModelOutput` only runs when the SDK has your tools at conversion time. On a multi-turn parent, the SDK re-converts the persisted history at the start of each turn, so you must declare the sub-agent tool on the agent config (`chat.agent({ tools })`) for the compression to survive. Without it, the summary holds on turn 1 but turn 2 onward re-ingests the full sub-agent output. In a `chat.customAgent` loop you own the conversion, so pass the tools to `convertToModelMessages(uiMessages, { tools })` yourself. See [Tools: toModelOutput across turns](/ai-chat/tools#tomodeloutput-across-turns). ## ChatStream.messages() The `messages()` method on `ChatStream` wraps the AI SDK's `readUIMessageStream`. It reads the raw `UIMessageChunk` stream and yields complete `UIMessage` snapshots — each containing all parts received so far. ```ts const stream = await chat.sendMessage("Research this topic"); // Each yield is a complete UIMessage with all accumulated parts for await (const message of stream.messages()) { console.log(message.parts.length, "parts so far"); } ``` For the sub-agent pattern, use `yield*` to delegate all yields to the parent tool's generator: ```ts execute: async function* ({ topic }, { abortSignal }) { const stream = await chat.sendMessage(topic, { abortSignal }); yield* stream.messages(); }, ``` `stream.messages()` consumes the stream. You can't also call `stream.text()` or iterate over chunks on the same stream. Pick one consumption mode. ## Combining with chat.agent() Sub-agent tools work inside both `chat.agent()` (managed) and `chat.customAgent()` (manual lifecycle): ```ts // Managed agent with sub-agent tool const tools = { research: researchTool }; export const myAgent = chat.agent({ id: "orchestrator", tools, // declare here so toModelOutput survives across turns run: async ({ messages, tools, stopSignal }) => { return streamText({ model: anthropic("claude-sonnet-4-6"), messages, tools, abortSignal: stopSignal, stopWhen: stepCountIs(15), }); }, }); ``` For `chat.customAgent()`, define the tool and sub-agent Map inside the `run` closure so they survive across turns. Since you own the turn loop there, convert history with your tools in scope so `toModelOutput` is re-applied each turn: `convertToModelMessages(uiMessages, { tools })`. See [Tools: manual turn loops](/ai-chat/tools#manual-turn-loops-chatcustomagent). ## Streaming progress from a subtask to the parent chat When a tool invokes a subtask via `triggerAndWait`, the subtask can stream custom data parts directly to the parent chat using `chat.stream.writer({ target: "root" })`. The frontend receives these as `DataUIPart` objects in `message.parts` on the **parent's** message stream: ```ts import { chat, ai } from "@trigger.dev/sdk/ai"; import { schemaTask } from "@trigger.dev/sdk"; import { streamText, tool, generateId } from "ai"; import { anthropic } from "@ai-sdk/anthropic"; import { z } from "zod"; export const researchTask = schemaTask({ id: "research", schema: z.object({ query: z.string() }), run: async ({ query }) => { const partId = generateId(); // Stream a data-* chunk to the root run's chat stream. const { waitUntilComplete } = chat.stream.writer({ target: "root", execute: ({ write }) => { write({ type: "data-research-status", id: partId, data: { query, status: "in-progress" }, }); }, }); await waitUntilComplete(); const result = await doResearch(query); // Update the same part with the final status — same type + id replaces it. const { waitUntilComplete: waitDone } = chat.stream.writer({ target: "root", execute: ({ write }) => { write({ type: "data-research-status", id: partId, data: { query, status: "done", resultCount: result.length }, }); }, }); await waitDone(); return result; }, }); const research = tool({ description: researchTask.description ?? "", inputSchema: researchTask.schema!, execute: ai.toolExecute(researchTask), }); ``` On the frontend, render the custom data part: ```tsx {message.parts.map((part, i) => { if (part.type === "data-research-status") { const { query, status, resultCount } = part.data; return (
{status === "done" ? `Found ${resultCount} results` : `Researching "${query}"...`}
); } // ...other part types })} ``` The `target` option accepts: - `"self"` — current run (default) - `"parent"` — parent task's run - `"root"` — root task's run (the chat agent) - A specific run ID string ## Inside `ai.toolExecute`: accessing tool + chat context When a subtask runs via `execute: ai.toolExecute(task)`, it can read the parent's tool call ID and chat context from inside the subtask body: ```ts import { ai, chat } from "@trigger.dev/sdk/ai"; import type { myChat } from "./chat"; export const mySubtask = schemaTask({ id: "my-subtask", schema: z.object({ query: z.string() }), run: async ({ query }) => { // The AI SDK tool call ID — useful as a stable `data-*` chunk id const toolCallId = ai.toolCallId(); // Typed chat context — `clientData` is typed off your chat's `clientDataSchema` const { chatId, clientData } = ai.chatContextOrThrow(); const { waitUntilComplete } = chat.stream.writer({ target: "root", execute: ({ write }) => { write({ type: "data-progress", id: toolCallId, data: { status: "working", query, userId: clientData?.userId }, }); }, }); await waitUntilComplete(); return { result: "done" }; }, }); ``` | Helper | Returns | Description | |--------|---------|-------------| | `ai.toolCallId()` | `string \| undefined` | The AI SDK tool call ID | | `ai.chatContext()` | `{ chatId, turn, continuation, clientData } \| undefined` | Chat context with typed `clientData`. Returns `undefined` if not in a chat context. | | `ai.chatContextOrThrow()` | `{ chatId, turn, continuation, clientData }` | Same as above but throws if not in a chat context | | `ai.currentToolOptions()` | `ToolCallExecutionOptions \| undefined` | Full tool execution options | The subtask body also has read-only access to any [`chat.local`](/ai-chat/chat-local) values initialized in the parent — auto-hydrated from the parent's metadata on first access.